1<!DOCTYPE html> 2<html lang="en"> 3 4<head> 5
5<script> 6 // View Transitions reject .ready/.finished when a transition is interrupted (fast 7 // navigation, tab hidden, viewport resized). These are expected and benign, but show 8 // up as noisy unhandled rejections (AbortError / InvalidStateError) in error tracking. 9 // Swallow only those, tightly scoped to transition-related DOMExceptions. 10 window.addEventListener('unhandledrejection', function (e) { 11 var r = e.reason; 12 if (r && (r.name === 'AbortError' || r.name === 'InvalidStateError') 13 && typeof r.message === 'string' && /transition/i.test(r.message)) { 14 e.preventDefault(); 15 } 16 }); 17 18 // Direction-aware cross-document page transitions. Tag <html> so CSS slides content 19 // in from the right on forward navigations and from the left when going back/forward 20 // through history. Registered before first paint so it catches the pagereveal event. 21 window.addEventListener('pagereveal', function (e) { 22 if (!e.viewTransition) return; 23 var dir = 'forward'; 24 try { 25 var act = window.navigation && window.navigation.activation; 26 if (act) { 27 if (act.navigationType === 'reload') { dir = null; } 28 else if (act.navigationType === 'traverse' && act.from && act.entry) { 29 dir = act.entry.index < act.from.index ? 'back' : 'forward'; 30 } 31 } 32 } catch (_) {} 33 var root = document.documentElement; 34 root.classList.remove('vt-forward', 'vt-back'); 35 if (dir) root.classList.add(dir === 'back' ? 'vt-back' : 'vt-forward'); 36 e.viewTransition.finished.finally(function () { 37 root.classList.remove('vt-forward', 'vt-back'); 38 }); 39 }); 40 </script>
40 41
41<script> 42 // Set theme synchronously before first paint to avoid FOUC. The class is mirrored onto <body> 43 // by an inline script at the top of <body> so `body.dark-theme` selectors still match. 44 (function(){var d=localStorage.getItem('darkTheme');var dark=d?d==='true':window.matchMedia('(prefers-color-scheme: dark)').matches;if(dark)document.documentElement.classList.add('dark-theme');})(); 45 // Palette choice â re-apply the accent chosen via the navbar palette dropdown, before first paint. 46 (function(){try{var p=JSON.parse(localStorage.getItem('ss_palette')||'null');if(p&&p.h){var s=document.documentElement.style;s.setProperty('--accent-h',p.h);s.setProperty('--accent-s',p.s);s.setProperty('--accent-l',p.l);if(p.rgb)s.setProperty('--color-primary-rgb',p.rgb);}}catch(e){}})(); 47 </script>
47 48
48<script> 49 (function () { 50 function isWeakGPU(renderer) { 51 if (!renderer || renderer === 'Unknown') return false; 52 var r = renderer.toLowerCase(); 53 // Intel integrated 54 if (r.indexOf('intel') !== -1 && ( 55 r.indexOf('hd graphics') !== -1 || 56 r.indexOf('uhd graphics') !== -1 || 57 r.indexOf('iris') !== -1 58 )) return true; 59 // AMD APU integrated (no discrete RX/Pro) 60 if (r.indexOf('amd') !== -1 && r.indexOf('radeon') !== -1 && 61 (r.indexOf('vega') !== -1 || r.indexOf('graphics') !== -1) && 62 r.indexOf(' rx ') === -1 && r.indexOf(' pro ') === -1) return true; 63 // Software renderers 64 if (r.indexOf('microsoft basic render') !== -1 || 65 r.indexOf('swiftshader') !== -1 || 66 r.indexOf('llvmpipe') !== -1) return true; 67 return false; 68 } 69 try { 70 var c = document.createElement('canvas'); 71 var gl = c.getContext('webgl2') || c.getContext('webgl') || c.getContext('experimental-webgl'); 72 if (!gl) { 73 document.documentElement.classList.add('no-gpu'); 74 } else { 75 var dbg = gl.getExtension('WEBGL_debug_renderer_info'); 76 var renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : ''; 77 if (isWeakGPU(renderer)) document.documentElement.classList.add('no-gpu'); 78 } 79 } catch (e) { document.documentElement.classList.add('no-gpu'); } 80 })(); 81 </script>
81 82 <!-- ========= SENTRY ========= --> 83
83<script src="https://browser.sentry-cdn.com/9.18.0/bundle.tracing.replay.feedback.min.js" 84 integrity="sha384-AjEie096M0si7Z7evQkKpGRBFXtrRj0v7pb1FTQau13fhSYTdtEgfW85kTIrWdBe" 85 crossorigin="anonymous"></script>
85 86
86<script> 87 // Guard against the CDN script being blocked (ad blockers, offline, integrity failure) 88 if (typeof Sentry !== 'undefined') { 89 Sentry.init({ 90 dsn: "https://05d1a950ad00f7142bea03c3e956de46@o4509799317897216.ingest.us.sentry.io/4509799330152448", 91 integrations: [ 92 Sentry.browserTracingIntegration(), 93 Sentry.replayIntegration({ 94 maskAllText: false, 95 blockAllMedia: false, 96 }), 97 Sentry.feedbackIntegration({ 98 colorScheme: "dark", 99 // Don't inject the floating widget; attach to the navbar button instead 100 // so it can never overlap the download-queue panel. 101 autoInject: false, 102 }), 103 ], 104 // Anchored to our own origin so the trace header is never attached to 105 // third-party requests (e.g. Beamer) whose URLs merely contain our 106 // domain as a query param â that triggers a CORS preflight rejection. 107 tracePropagationTargets: [/^https:\/\/(www\.)?streamsnip\.com/, /^\//], 108 // Sampled down from 1.0/0.1/1.0: replay's DOM recorder and tracing ran for 109 // every visitor (error replay buffers even on unsampled sessions), adding 110 // measurable main-thread overhead. ~90% of sessions now record nothing. 111 tracesSampleRate: 0.1, 112 replaysSessionSampleRate: 0.01, 113 replaysOnErrorSampleRate: 0.1, 114 // Crashes thrown entirely inside a visitor's browser extension are not our 115 // bugs and we can't fix them â keep them out of the issue stream. 116 denyUrls: [/^chrome-extension:\/\//i, /^moz-extension:\/\//i, /^safari-web-extension:\/\//i], 117 beforeSend(event, hint) { 118 // View Transitions reject .ready/.finished when interrupted (fast nav, tab hidden, 119 // viewport resized), surfacing as benign AbortError/InvalidStateError unhandled 120 // rejections. Sentry's own unhandledrejection handler captures these regardless of 121 // the global preventDefault() handler, so they have to be dropped here at the SDK level. 122 var ex = hint && hint.originalException; 123 if (ex && (ex.name === 'AbortError' || ex.name === 'InvalidStateError') 124 && typeof ex.message === 'string' && /transition/i.test(ex.message)) { 125 return null; 126 } 127 128 return event; 129 }, 130 }); 131 // The trigger lives in the navbar (rendered later in <body>), so wait for the DOM. 132 document.addEventListener('DOMContentLoaded', function () { 133 const btn = document.getElementById('report-bug-btn'); 134 if (btn) Sentry.getFeedback()?.attachTo(btn, { formTitle: "Report a bug" }); 135 }); 136 } 137 </script>
137 138 139 <!-- ========= GOOGLE ANALYTICS (GA4) ========= --> 140 141 <!-- No SRI: Google serves gtag.js dynamically per id and updates it often, so a pinned hash would break it. -->
vendor: 66 bytes, lines 141-142
141 142 <script async src="https://www.googletagmanager.com/gtag/js?id=
142G-1W58W85E8M
vendor: 14 bytes, lines 142-143
142"></script> 143
143<script> 144
vendor: 139 bytes, lines 144-147
144window.dataLayer = window.dataLayer || []; 145 function gtag(){dataLayer.push(arguments);} 146 gtag('js', new Date()); 147 gtag('config', "
147G-1W58W85E8M
vendor: 6 bytes, lines 147-148
147"); 148
148</script>
148 149 150 151 <!-- ========= META & SEO ========= --> 152 <meta charset="utf-8" /> 153 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> 154 155 <title>Login to StreamSnip</title> 156 <meta name="description" 157 content="Log in to your StreamSnip account to manage your clips, view channel statistics, and update your settings." /> 158 <meta name="keywords" 159 content="Livestream, YouTube, StreamSnip, !clip, Nightbot, Nightbot-clip, YouTube Clipping, Stream Moments" /> 160 <meta name="author" content="Suraj Bhari" /> 161 <meta name="copyright" content="© 2026 Suraj Bhari. All rights reserved." /> 162 163 <meta name="robots" 164 content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1, noai, noimageai" /> 165 <link rel="canonical" href="https://streamsnip.com/login" /> 166 <link rel="manifest" href="/static/manifest.json" /> 167 <meta name="theme-color" content="#0080ff" /> 168 <meta name="mobile-web-app-capable" content="yes" /> 169 <meta name="apple-mobile-web-app-capable" content="yes" /> 170 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> 171 <meta name="apple-mobile-web-app-title" content="StreamSnip" /> 172 173 <!-- Open Graph / Facebook --> 174 <meta property="og:type" content="website" /> 175 <meta property="og:url" content="https://streamsnip.com/login" /> 176 <meta property="og:title" content="Login to StreamSnip" /> 177 <meta property="og:description" content="Log in to your StreamSnip account to manage your clips, view channel statistics, and update your settings." /> 178 <meta property="og:image" 179 content="https://streamsnip.com/static/logo.png?v=7142fd7189" /> 180 <meta property="og:image:alt" content="Login to StreamSnip" /> 181 <meta property="og:site_name" content="StreamSnip" /> 182 <meta property="og:locale" content="en_US" /> 183 184 <!-- Twitter --> 185 <meta name="twitter:card" content="summary_large_image" /> 186 <meta name="twitter:url" content="https://streamsnip.com/login" /> 187 <meta name="twitter:title" content="Login to StreamSnip" /> 188 <meta name="twitter:description" content="Log in to your StreamSnip account to manage your clips, view channel statistics, and update your settings." /> 189 <meta name="twitter:image" content="https://streamsnip.com/static/logo.png?v=7142fd7189" /> 190 <meta name="twitter:image:alt" content="Login to StreamSnip" /> 191 192 <!-- Favicon --> 193 <link rel="icon" type="image/png" href="/static/logo.png?v=7142fd7189" /> 194 <link rel="apple-touch-icon" href="/static/logo.png?v=7142fd7189" /> 195 196 <!-- ========= STYLES & CRITICAL ASSETS PRELOAD ========= --> 197 <link rel="dns-prefetch" href="https://fonts.googleapis.com" /> 198 <link rel="dns-prefetch" href="https://fonts.gstatic.com" /> 199 <link rel="dns-prefetch" href="https://use.fontawesome.com" /> 200 <link rel="dns-prefetch" href="https://cdn.jsdelivr.net" /> 201 202 <link rel="preconnect" href="https://fonts.googleapis.com" /> 203 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 204 <link rel="preconnect" href="https://use.fontawesome.com" crossorigin /> 205 <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin /> 206 207 <link rel="preload" as="style" href="/static/style.css?v=2632f62682" /> 208 <link rel="preload" as="style" 209 href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=Poppins:wght@400;600&family=Rajdhani:wght@600;700&display=swap" /> 210 <link rel="preload" as="image" href="/static/logo-grey.svg" /> 211 <link rel="preload" as="image" href="/static/logo.svg" /> 212 213 <!-- FontAwesome is icons only (not layout) â load it off the render-blocking path so the 214 page content paints without waiting on this CDN request. Falls back to a blocking 215 load with JS disabled. --> 216 <link rel="preload" as="style" href="https://use.fontawesome.com/releases/v6.5.1/css/all.css" 217 onload="this.onload=null;this.rel='stylesheet'" /> 218 <noscript> 219 <link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.5.1/css/all.css" /> 220 </noscript> 221 <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" 222 integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous" /> 223 <link 224 href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;
224800&family=Poppins:wght@400;600&family=Rajdhani:wght@600;700&display=swap" 225 rel="stylesheet" /> 226 227 <link rel="stylesheet" href="/static/style.css?v=2632f62682" /> 228 229 <style> 230 body { 231 -webkit-font-smoothing: antialiased; 232 -moz-osx-font-smoothing: grayscale; 233 } 234 235 .btn:active, 236 .nav-link:active { 237 transform: scale(0.96) !important; 238 transition: transform 0.1s cubic-bezier(0.4, 0, 0.2, 1) !important; 239 } 240 241 h1, 242 h2, 243 h3, 244 h4, 245 h5, 246 h6 { 247 text-wrap: balance; 248 } 249 250 p { 251 text-wrap: pretty; 252 } 253 254 .profile-frame img { 255 box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); 256 } 257 258 .dark-theme .profile-frame img { 259 box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1); 260 } 261 262 .site_icon, 263 #site_icon, 264 .site-icon, 265 .navbar-brand, 266 .navbar-brand img, 267 .navbar-brand:focus, 268 .navbar-brand:active { 269 box-shadow: none !important; 270 outline: none !important; 271 border: none !important; 272 } 273 274 /* Navbar clapper logo */ 275 #_nbl_stick, #_nbl_clapper { fill: #444444; } 276 body.dark-theme #_nbl_stick, body.dark-theme #_nbl_clapper { fill: #fffcfc; } 277 #_nbl_svg { transform: rotate(17deg); transform-origin: center; } 278 @keyframes _nbl_impact { 279 0% { transform: rotate(17deg) scale(1) translateX(0px); } 280 51% { transform: rotate(17deg) scale(1.07) translateX(0px); animation-timing-function: cubic-bezier(0.36,0.07,0.19,0.97); } 281 53% { transform: rotate(17deg) scale(1.05) translateX(-5px); } 282 55% { transform: rotate(17deg) scale(1.04) translateX(5px); } 283 57% { transform: rotate(17deg) scale(1.02) translateX(-3px); } 284 59% { transform: rotate(17deg) scale(1.01) translateX(2px); } 285 63% { transform: rotate(17deg) scale(1) translateX(0px); } 286 100% { transform: rotate(17deg) scale(1) translateX(0px); } 287 } 288 #_nbl_svg._nbl_play { animation: _nbl_impact 2.6s linear 1 forwards; } 289 290 /* ===== Clapper loading animation: a looping version of the navbar logo's clap ===== */ 291 /* Faithful loop of the navbar clap: ease-out open â HOLD open â snap shut â 292 bounce â hold closed â settle to rest â brief pause, then repeat. */ 293 @keyframes ss-clap { 294 0% { transform: rotate(-17deg); animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } 295 16% { transform: rotate(-57deg); animation-timing-function: linear; } 296 22% { transform: rotate(-57deg); animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); } 297 40% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.3, 1); } 298 45% { transform: rotate(-8deg); animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1); } 299 51% { transform: rotate(0deg); animation-timing-function: linear; } 300 62% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } 301 78% { transform: rotate(-17deg); } 302 100% { transform: rotate(-17deg); } 303 } 304 /* Whole-board recoil: scale punch + damped horizontal shake, synced to the snap (40%). */ 305 @keyframes ss-clap-impact { 306 0% { transform: rotate(17deg) scale(1) translateX(0); } 307 40% { transform: rotate(17deg) scale(1.07) translateX(0); animation-timing-function: cubic-bezier(0.36, 0.07, 0.19, 0.97); } 308 41.5% { transform: rotate(17deg) scale(1.05) translateX(-5px); } 309 43% { transform: rotate(17deg) scale(1.04) translateX(5px); } 310 44.5% { transform: rotate(17deg) scale(1.02) translateX(-3px); } 311 46% { transform: rotate(17deg) scale(1.01) translateX(2px); } 312 49% { transform: rotate(17deg) scale(1) translateX(0); } 313 100% { transform: rotate(17deg) scale(1) translateX(0); } 314 } 315 .ss-clap { 316 width: 48px; height: 48px; display: block; overflow: visible; 317 transform: rotate(17deg); transform-origin: center; 318 animation: ss-clap-impact 1.6s linear infinite; 319 } 320 .ss-clap .ss-clap-stick { transform-origin: 958px 614px; animation: ss-clap 1.6s linear infinite; } 321 .ss-clap .ss-clap-stick, .ss-clap .ss-clap-board { fill: #444444; transition: fill .3s ease; } 322 body.dark-theme .ss-clap .ss-clap-stick, body.dark-theme .ss-clap .ss-clap-board { fill: #fffcfc; } 323 @media (prefers-reduced-motion: reduce) { 324 .ss-clap, .ss-clap .ss-clap-stick { animation-duration: 2.4s; } 325 } 326 .clap-loader-box { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .85rem; } 327 .clap-loader-box.inline { flex-direction: row; gap: .55rem; } 328 .clap-loader-label { font-size: .9rem; color: var(--color-text-muted); font-weight: 500; } 329 </style> 330 <!-- ========= SPECULATION RULES ========= 331 Make internal navigation feel instant, pairing with the cross-document View 332 Transitions above. `prefetch` only fetches the HTML (no script execution), so it
333 never triggers a page's analytics/view-tracking or YouTube loads early â safe to 334 apply broadly. `prerender` fully loads + runs the target, so it's limited to the 335 static informational pages where running their JS ahead of time has no side 336 effects. Auth/download/payment/API routes are excluded from both. `moderate` 337 eagerness fires on hover / pointerdown intent rather than eagerly on render. --> 338
338<script type="speculationrules"> 339 { 340 "prefetch": [{ 341 "where": { "and": [ 342 { "href_matches": "/*" }, 343 { "not": { "href_matches": "/logout*" } }, 344 { "not": { "href_matches": "/login*" } }, 345 { "not": { "href_matches": "/download*" } }, 346 { "not": { "href_matches": "/video/*" } }, 347 { "not": { "href_matches": "/api/*" } }, 348 { "not": { "href_matches": "/pay*" } }, 349 { "not": { "href_matches": "/gift/*" } }, 350 { "not": { "selector_matches": "[data-no-prefetch]" } }, 351 { "not": { "selector_matches": "[target=_blank]" } } 352 ]}, 353 "eagerness": "moderate" 354 }], 355 "prerender": [ 356 357 { 358 "where": { "or": [ 359 { "href_matches": "/e/*" }, 360 { "href_matches": "/exports/*" }, 361 { "href_matches": "/get-started" }, 362 { "href_matches": "/guides" }, 363 { "href_matches": "/aboutus" }, 364 { "href_matches": "/contactus" }, 365 { "href_matches": "/privacy-policy" }, 366 { "href_matches": "/tnc" }, 367 { "href_matches": "/stats" }, 368 { "href_matches": "/membership" }, 369 { "href_matches": "/all_games" } 370 ]}, 371 "eagerness": "moderate" 372 }] 373 } 374 </script>
374 375 376 <!-- ========= STRUCTURED DATA: Organization + WebSite (site-wide entity graph) ========= --> 377
377<script type="application/ld+json"> 378 { 379 "@context": "https://schema.org", 380 "@graph": [ 381 { 382 "@type": "Organization", 383 "@id": "https://streamsnip.com/#organization", 384 "name": "StreamSnip", 385 "url": "https://streamsnip.com", 386 "logo": { 387 "@type": "ImageObject", 388 "url": "https://streamsnip.com/static/logo.png", 389 "width": 512, 390 "height": 512 391 }, 392 "description": "StreamSnip is a freemium clipping tool for YouTube livestreamers. Capture highlight moments instantly with a single chat command.", 393 "foundingDate": "2023-01-24", 394 "founder": { 395 "@type": "Person", 396 "@id": "https://streamsnip.com/#founder", 397 "name": "Suraj Bhari" 398 }, 399 "contactPoint": { 400 "@type": "ContactPoint", 401 "contactType": "customer support", 402 "email": "[email protected]", 403 "url": "https://streamsnip.com/contactus" 404 }, 405 "sameAs": [ 406 "https://discord.gg/2XVBWK99Vy" 407 ] 408 }, 409 { 410 "@type": "WebSite", 411 "@id": "https://streamsnip.com/#website", 412 "url": "https://streamsnip.com", 413 "name": "StreamSnip", 414 "description": "Simplified YouTube livestream clipping for streamers and their communities.", 415 "publisher": { 416 "@id": "https://streamsnip.com/#organization" 417 } 418 } 419 ] 420 } 421 </script>
421 422 423 424<style> 425 /* ââ Full-page layout: push footer to bottom ââ */ 426 .login-page { 427 display: flex; 428 flex-direction: column; 429 flex: 1; 430 position: relative; 431 overflow: hidden; 432 } 433 434 /* ââ Hero background (star warp canvas) ââ */ 435 .login-hero-bg { 436 position: fixed; 437 top: 0; 438 left: 0; 439 width: 100%; 440 height: 100%; 441 z-index: 0; 442 background: radial-gradient(circle at center, hsl(var(--accent-h) 36% 97%) 0%, var(--color-canvas) 100%); 443 transition: background 0.5s ease; 444 } 445 446 .dark-theme .login-hero-bg { 447 background: radial-gradient(circle at center, hsl(var(--accent-h) 30% 12%) 0%, var(--color-canvas) 100%); 448 } 449 450 #login-canvas { 451 position: absolute; 452 top: 0; 453 left: 0; 454 width: 100%; 455 height: 100%; 456 pointer-events: none; 457 } 458 459 /* ââ Center the login card ââ */ 460 .login-wrapper { 461 flex: 1; 462 display: flex; 463 align-items: center; 464 justify-content: center; 465 padding: 2rem 1rem; 466 position: relative; 467 z-index: 2; 468 } 469 470 .login-card { 471 width: 100%; 472 max-width: 420px; 473 padding: 2.5rem; 474 transition: transform var(--transition-base), box-shadow var(--transition-base); 475 } 476 477 .login-card:hover { 478 transform: translateY(-4px); 479 box-shadow: 0 12px 40px 0 rgba(31, 38, 135, 0.18); 480 } 481 482 .login-header { 483 text-align: center; 484 margin-bottom: 2rem; 485 } 486 487 .login-header h1 { 488 font-family: 'Outfit', sans-serif; 489 font-weight: 700; 490 font-size: 2rem; 491 margin-bottom: 0; 492 color: var(--color-primary); 493 text-wrap: balance; 494 } 495 496 /* ââ Modern Input ââ */ 497 .input-group-custom { 498 margin-bottom: 1.25rem; 499 position: relative; 500 } 501 502 .input-group-custom i { 503 position: absolute; 504 inset-inline-start: 1.25rem; 505 top: 50%; 506 transform: translateY(-50%); 507 color: var(--color-primary); 508 opacity: 0.6; 509 font-size: 0.9rem; 510 } 511 512 .glass-input { 513 width: 100%; 514 background: rgba(255, 255, 255, 0.05); 515 border: 1px solid var(--glass-border); 516 border-radius: var(--radius-md); 517 padding-block: 0.9rem; 518 padding-inline: 3.25rem 1rem; 519 color: var(--color-text); 520 font-size: 0.95rem; 521 transition: background-color var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base); 522 } 523 524 .glass-input::placeholder { 525 color: var(--color-text-muted); 526 opacity: 0.6; 527 } 528 529 .glass-input:focus { 530 background: rgba(255, 255, 255, 0.08); 531 border-color: var(--color-primary); 532 box-shadow: 0 0 0 3px var(--color-primary-glow); 533 outline: none; 534 } 535 536 /* ââ Remember checkbox ââ */ 537 .remember-row { 538 display: flex; 539 align-items: center; 540 justify-content: space-between; 541 margin-bottom: 1.5rem; 542 } 543 544 .remember-label { 545 display: flex; 546 align-items: center; 547 gap: 0.5rem; 548 cursor: pointer; 549 user-select: none; 550 color: var(--color-text-muted); 551 font-size: 0.875rem; 552 margin: 0; 553 } 554 555 .remember-label input[type="checkbox"] { 556 accent-color: var(--color-primary); 557 width: 1rem; 558 height: 1rem; 559 cursor: pointer; 560 } 561 562 .msg-hint { 563 font-size: 0.8rem; 564 color: var(--color-text-muted); 565 text-align: center; 566 margin: 1.25rem 0 0; 567 line-height: 1.4; 568 opacity: 0.7; 569 } 570 571 /* ââ Divider ââ */ 572 .divider { 573 display: flex; 574 align-items: center; 575 text-align: center; 576 margin: 1.75rem 0; 577 color: var(--color-text-muted); 578 font-size: 0.8rem; 579 text-transform: uppercase; 580 letter-spacing: 0.05em; 581 } 582 583 .divider::before, .divider::after { 584 content: ''; 585 flex: 1; 586 border-bottom: 1px solid var(--glass-border); 587 } 588 589 .divider:not(:empty)::before { margin-inline-end: .75rem; } 590 .divider:not(:empty)::after { margin-inline-start: .75rem; } 591 592 /* ââ Modern Google Button ââ */ 593 .google-btn { 594 display: flex; 595 align-items: center; 596 justify-content: center; 597 gap: 0.75rem; 598 width: 100%; 599 padding: 0.75rem 1.5rem; 600 border-radius: var(--radius-md); 601 border: 1px solid var(--glass-border); 602 background: rgba(255,255,255,0.05); 603 color: var(--color-text); 604 font-size: 0.95rem; 605 font-weight: 500; 606 text-decoration: none; 607 cursor: pointer; 608 transition: background-color var(--transition-base), border-color var(--transition-base), transform var(--transition-base), box-shadow var(--transition-base), color var(--transition-base); 609 } 610 611 .google-btn:hover { 612 background: rgba(255,255,255,0.12); 613 border-color: var(--color-primary); 614 transform: translateY(-1px); 615 box-shadow: 0 4px 12px rgba(0,0,0,0.1); 616 color: var(--color-text); 617 text-decoration: none; 618 } 619 620 .google-btn:active { 621 transform: scale(0.96); 622 } 623 624 .btn:active { 625 transform: scale(0.96); 626 transition: transform 0.1s ease; 627 } 628 629 .google-btn svg {
630 width: 20px; 631 height: 20px; 632 flex-shrink: 0; 633 } 634 635 .signup-hint { 636 text-align: center; 637 margin-top: 1.5rem; 638 font-size: 0.875rem; 639 color: var(--color-text-muted); 640 } 641 642 .signup-hint a { 643 color: var(--color-primary); 644 font-weight: 600; 645 text-decoration: none; 646 } 647 648 .signup-hint a:hover { 649 text-decoration: underline; 650 } 651</style> 652 653</head> 654 655<body> 656
656<script> 657 // Mirror the early <html> dark-theme class onto <body> so existing `body.dark-theme` rules apply before first paint. 658 if (document.documentElement.classList.contains('dark-theme')) document.body.classList.add('dark-theme'); 659 </script>
659 660 661 <!-- ssIsTypingTarget / ssRegisterShortcuts. Not deferred: page scripts that bind 662 shortcuts at parse time call into it. --> 663
663<script src="/static/js/shortcuts.js?v=c98906de16"></script>
663 664 <!-- ssConfirm â themed replacement for window.confirm(). --> 665
665<script src="/static/js/confirm.js?v=8a2fd43037" defer></script>
665 666 667 <!-- ===== Reusable clapper loader (single source of truth) ===== 668 Server-rendered: <div data-clap-loader data-size="48" data-label="â¦"></div> (auto-hydrated). 669 JS-injected: window.clapLoaderHTML({size, label, inline}) / window.makeClapLoader(...). --> 670 <template id="ss-clap-tpl"> 671 <svg class="ss-clap" viewBox="0 0 3334 3334" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"> 672 <path class="ss-clap-stick" fill-rule="evenodd" d="M 2630.692 408.528 C 2630.622 408.998 2932.538 739.198 2932.538 739.198 L 2935.557 411.334 M 2329.824 405.758 C 2329.564 405.972 1890.172 401.018 1890.292 401.711 C 1890.412 402.336 2203.145 732.254 2205.705 732.506 C 2207.355 732.67 2629.269 736.133 2629.769 735.802 M 1532.807 398.421 C 1532.557 398.615 1083.58 393.355 1083.68 394.286 C 1083.76 395.084 1399.75 724.278 1400.29 724.803 C 1400.82 725.329 1835.934 729.102 1835.931 729.103 M 471.788 388.873 L 468.835 716.516 C 468.835 716.516 1089.13 725.939 1089.05 724.765 C 1088.98 723.601 779.835 391.763 776.285 391.456"/> 673 <path class="ss-clap-board" fill-rule="evenodd" 674 transform="matrix(0.95517403, -0.29604402, 0.29604402, 0.95517403, -456.95786825, 588.0155987)" 675 d="M 524.433 1432.53 C 365.242 1947.64 234.251 2372.63 233.351 2376.95 C 232.451 2381.27 231.481 2389.75 231.191 2395.8 C 230.841 2403.26 231.181 2410.12 232.261 2417.13 C 233.131 2422.81 235.451 2432.26 237.411 2438.13 C 239.381 2444 242.971 2452.44 245.401 2456.9 C 247.831 2461.35 251.911 2467.88 254.481 2471.4 C 257.041 2474.92 262.871 2481.46 267.431 2485.94 C 271.991 2490.42 279.241 2496.41 283.551 2499.25 C 287.851 2502.1 295.001 2506.17 299.451 2508.31 C 305.091 2511.02 623.563 2609.86 1356.05 2836.23 C 1932.73 3014.45 2408.35 3161.15 2412.97 3162.23 C 2417.59 3163.32 2425.42 3164.74 2430.37 3165.38 C 2436.04 3166.13 2445.47 3166.35 2455.87 3165.98 C 2466.63 3165.6 2475.74 3164.68 2482.05 3163.34 C 2487.37 3162.21 2495.92 3159.92 2501.05 3158.25 C 2506.18 3156.57 2515.08 3152.96 2520.84 3150.21 C 2526.6 3147.46 2535.6 3142.35 2540.85 3138.86 C 2546.09 3135.37 2554.2 3129.17 2558.87 3125.08 C 2563.55 3121 2570.58 3113.86 2574.51 3109.23 C 2578.43 3104.59 2584.16 3097.03 2587.23 3092.43 C 2590.31 3087.83 2595.04 3079.5 2597.75 3073.93 C 2600.47 3068.36 2604.3 3058.85 2606.28 3052.8 C 2608.26 3046.75 2741.7 2615.21 2902.81 2093.83 C 3063.93 1572.44 3198.395 1137.842 3198.215 1137.672 C 3198.045 1137.492 2817.49 1343.35 2817.49 1343.35 C 2817.49 1343.35 2528.38 1254.14 2528.24 1254 C 2528.1 1253.86 2909.86 1047.81 2908.62 1047.27 C 2907.38 1046.73 2503.85 922.031 2503.22 921.811 C 2502.59 921.601 2109.55 1124.54 2109.55 1124.54 C 2109.55 1124.54 1767.13 1018.87 1765.62 1018.58 C 1764.11 1018.3 2151.16 813.221 2150.17 812.831 C 2149.18 812.441 1730.94 683.301 1730.12 683.301 C 1729.31 683.301 1363.89 871.901 1354.74 876.651 C 1345.59 881.401 1337.18 885.291 1336.04 885.291 C 1334.9 885.291 1062.03 800.871 1055.5 798.831 C 1048.97 796.791 1044.03 794.771 1044.5 794.341 C 1044.98 793.921 1433.417 591.777 1433.197 590.627 C 1432.977 589.477 881.045 419.444 844.455 408.154 M 943.532 525.271 C 940.872 525.761 935.492 527.121 931.582 528.311 C 927.672 529.501 921.022 532.201 916.812 534.321 C 912.602 536.441 905.722 540.901 901.522 544.241 C 897.322 547.581 891.112 553.791 887.712 558.051 C 884.182 562.481 879.512 570.071 876.812 575.791 C 874.201 581.291 871.321 588.721 870.391 592.291 C 869.471 595.871 868.471 603.971 868.181 610.291 C 867.781 618.801 868.071 624.131 869.331 630.771 C 870.261 635.701 872.341 643.171 873.961 647.361 C 875.572 651.551 879.542 658.981 882.772 663.891 C 886.012 668.781 891.532 675.561 895.052 678.931 C 898.582 682.311 904.952 687.341 909.212 690.131 C 913.472 692.911 920.422 696.521 924.662 698.151 C 928.902 699.781 935.842 701.831 940.082 702.711 C 944.322 703.581 952.322 704.301 957.872 704.301 C 963.422 704.301 971.422 703.581 975.662 702.711 C 979.902 701.831 986.842 699.781 991.082 698.151 C 995.322 696.521 1002.27 692.911 1006.53 690.131 C 1010.79 687.341 1017.16 682.311 1020.67 678.931 C 1024.19 675.561 1029.48 669.171 1032.43 664.741 C 1035.38 660.311 1039.22 653.341 104
6750.96 649.241 C 1042.7 645.151 1044.96 637.741 1045.99 632.791 C 1047.01 627.841 1047.85 619.971 1047.85 615.291 C 1047.85 610.621 1047.39 603.871 1046.83 600.291 C 1046.26 596.721 1044.86 590.871 1043.7 587.291 C 1042.54 583.721 1039.88 577.371 1037.79 573.181 C 1035.7 569.001 1031.26 562.141 1027.92 557.941 C 1024.59 553.741 1018.58 547.701 1014.58 544.521 C 1010.58 541.341 1003.7 536.861 999.292 534.571 C 994.882 532.281 987.242 529.211 982.322 527.761 C 975.302 525.691 970.682 525.041 960.872 524.761 C 953.992 524.561 946.192 524.791 943.532 525.271 M 1097.12 1239.09 C 964.982 1320.88 856.881 1388.02 856.881 1388.3 C 856.891 1388.57 1003.82 1573.48 1183.41 1799.2 L 1509.93 2209.59 L 1427.65 2264.44 L 1345.37 2319.28 L 1152.07 2252.4 C 1045.75 2215.62 958.492 2185.25 958.152 2184.91 C 957.812 2184.57 957.722 2184.15 957.952 2183.96 C 958.182 2183.78 1016.2 2150.17 1086.87 2109.29 C 1157.55 2068.4 1216.6 2034.2 1218.11 2033.29 L 1220.84 2031.64 L 1145.29 1932.72 C 1103.73 1878.31 1069.31 1833.91 1068.79 1834.05 C 1068.26 1834.2 914.962 1929.76 728.101 2046.42 C 541.253 2163.08 387.822 2259.04 387.162 2259.66 C 386.242 2260.52 508.042 2300.95 886.112 2425.3 C 1161.21 2515.77 1386.88 2589.8 1387.61 2589.8 C 1388.34 2589.8 1495.56 2520.05 1625.87 2434.8 C 1759.06 2347.67 1862.63 2279.29 1862.4 2278.63 C 1862.18 2277.99 1715.65 2093.49 1536.79 1868.63 C 1357.93 1643.77 1211.2 1459.31 1210.74 1458.71 C 1210.18 1457.99 1237.69 1439.08 1292.07 1402.83 L 1374.26 1348.04 L 1468.67 1372.7 L 1563.09 1397.36 L 1687.73 1320.88 C 1756.28 1278.81 1813.17 1243.81 1814.16 1243.08 C 1815.7 1241.95 1784.17 1231.67 1577.66 1166 C 1446.6 1124.32 1338.92 1090.25 1338.37 1090.29 C 1337.82 1090.33 1229.26 1157.29 1097.12 1239.09 M 1717.29 1441.36 C 1586.98 1526.65 1480.26 1596.74 1480.14 1597.11 C 1480.01 1597.49 1626.84 1782.47 1806.43 2008.19 L 2132.96 2418.58 L 2050.66 2473.42 L 1968.37 2528.27 L 1874.5 2503.73 C 1822.87 2490.24 1780.1 2479.33 1779.45 2479.5 C 1778.8 2479.66 1721.93 2514.38 1653.07 2556.65 C 1584.21 2598.92 1527.87 2633.8 1527.87 2634.17 C 1527.87 2634.54 1635.09 2668.96 1766.14 2710.66 L 2004.42 2786.47 L 2245.1 2637.64 C 2388.34 2549.07 2485.63 2488.31 2485.38 2487.59 C 2485.15 2486.93 2338.63 2302.43 2159.78 2077.59 C 1980.93 1852.76 1834.22 1668.31 1833.75 1667.71 C 1833.18 1666.98 1860.5 1648.21 1915.06 1611.84 L 1997.23 1557.05 L 2012.3 1562.22 C 2020.59 1565.06 2107.62 1595.15 2205.7 1629.09 C 2303.78 1663.03 2384.03 1691.25 2384.02 1691.8 C 2384.02 1692.35 2325.36 1726.77 2253.66 1768.3 C 2181.96 1809.82 2123.09 1844.11 2122.84 1844.5 C 2122.58 1844.88 2156.34 1889.71 2197.87 1944.11 L 2273.37 2043.01 L 2279.37 2039.25 C 2282.67 2037.17 2436.12 1941.35 2620.37 1826.31 C 2804.62 1711.26 2955.75 1616.77 2956.21 1616.33 C 2956.67 1615.89 2952.84 1614.14 2947.71 1612.45 C 2942.57 1610.76 2717.3 1536.69 2447.11 1447.84 C 2176.92 1358.99 1955.48 1286.3 1955.03 1286.3 C 1954.57 1286.3 1847.59 1356.08 1717.29 1441.36"/> 676 </svg> 677 </template> 678
678<script> 679 (function () { 680 window.makeClapLoader = function (opts) { 681 opts = opts || {}; 682 const tpl = document.getElementById('ss-clap-tpl'); 683 const box = document.createElement('div'); 684 box.className = 'clap-loader-box' + (opts.inline ? ' inline' : ''); 685 if (tpl && tpl.content.firstElementChild) { 686 const svg = tpl.content.firstElementChild.cloneNode(true); 687 const size = opts.size || 48; 688 svg.style.width = size + 'px'; 689 svg.style.height = size + 'px'; 690 box.appendChild(svg); 691 } 692 if (opts.label) { 693 const l = document.createElement('div'); 694 l.className = 'clap-loader-label'; 695 l.textContent = opts.label; 696 box.appendChild(l); 697 } 698 return box; 699 }; 700 window.clapLoaderHTML = function (opts) { 701 const el = window.makeClapLoader(opts); 702 return el ? el.outerHTML : ''; 703 }; 704 // Just the clapper SVG (no box/label) for replacing inline <i> spinners inside text. 705 window.clapSvgHTML = function (size) { 706 const tpl = document.getElementById('ss-clap-tpl'); 707 if (!tpl || !tpl.content.firstElementChild) return ''; 708 const svg = tpl.content.firstElementChild.cloneNode(true); 709 const s = size || 20; 710 svg.style.width = s + 'px'; svg.style.height = s + 'px'; 711 svg.style.display = 'inline-block'; svg.style.verticalAlign = 'middle'; svg.style.marginRight = '.45em'; 712 return svg.outerHTML; 713 }; 714 function hydrate(root) { 715 (root || document).querySelectorAll('[data-clap-loader]:not([data-clap-done])').forEach(function (el) { 716 el.setAttribute('data-clap-done', '1'); 717 el.appendChild(window.makeClapLoader({ 718 size: parseInt(el.getAttribute('data-size'), 10) || 48, 719 label: el.getAttribute('data-label') || '', 720 inline: el.hasAttribute('data-inline') 721 })); 722 }); 723 } 724 window.hydrateClapLoaders = hydrate; 725 if (document.readyState === 'loading') { 726 document.addEventListener('DOMContentLoaded', function () { hydrate(); }); 727 } else { hydrate(); } 728 })(); 729 </script>
729 730 731 <!-- ========= NAVBAR ========= --> 732 <header class="fixed-top glass-nav"> 733 <div class="container"> 734 <nav class="navbar navbar-expand-lg navbar-dark" id="navbar_id"> 735 <a class="navbar-brand d-flex align-items-center" href="/" aria-label="StreamSnip home"> 736 <div class="me-2 site_icon" id="site_icon" style="width:36px;height:36px;flex-shrink:0;overflow:visible;cursor:pointer;"> 737 <svg id="_nbl_svg" viewBox="0 0 3334 3334" width="36" height="36" xmlns="http://www.w3.org/2000/svg" style="overflow:visible;display:block;" aria-hidden="true" focusable="false"> 738 <style> 739 @keyframes _nbl_clap { 740 0% { transform: rotate(-17deg); animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } 741 18% { transform: rotate(-57deg); animation-timing-function: linear; } 742 38% { transform: rotate(-57deg); animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); } 743 51% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0, 0, 0.3, 1); } 744 58% { transform: rotate(-8deg); animation-timing-function: cubic-bezier(0.25, 1, 0.5, 1); } 745 66% { transform: rotate(0deg); animation-timing-function: linear; } 746 80% { transform: rotate(0deg); animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); } 747 100% { transform: rotate(-17deg); } 748 } 749 #_nbl_stick { 750 transform-origin: 958px 614px; 751 transform: rotate(-17deg); 752 } 753 #_nbl_stick._nbl_play { 754 animation: _nbl_clap 2.6s linear 1 forwards; 755 } 756 </style> 757 <!-- Punch the hinge hole through the icon so the stick can't be seen through it 758 as it claps. SVG mask (true transparency for the glass navbar); region is set 759 huge so the swinging stick is never clipped. Circle = the board hole in 760 user space (cx 640, cy 891, r 112 of the 3334 viewBox). --> 761 <mask id="_nbl_holemask" maskUnits="userSpaceOnUse" x="-2000" y="-2000" width="8000" height="8000"> 762 <rect x="-2000" y="-2000" width="8000" height="8000" fill="#fff"/> 763 <circle cx="640" cy="891" r="112" fill="#000"/> 764 </mask> 765 <g mask="url(#_nbl_holemask)"> 766 <path id="_nbl_stick" fill-rule="evenodd" paint-order="stroke fill" 767 transform="matrix(0.999958, -0.009206, 0.009206, 0.999958, 0.019399, 0.012944)" 768 d="M 2630.692 408.528 C 2630.622 408.998 2932.538 739.198 2932.538 739.198 L 2935.557 411.334 M 2329.824 405.758 C 2329.564 405.972 1890.172 401.018 1890.292 401.711 C 1890.412 402.336 2203.145 732.254 2205.705 732.506 C 2207.355 732.67 2629.269 736.133 2629.769 735.802 M 1532.807 398.421 C 1532.557 398.615 1083.58 393.355 1083.68 394.286 C 1083.76 395.084 1399.75 724.278 1400.29 724.803 C 1400.82 725.329 1835.934 729.102 1835.931 729.103 M 471.788 388.873 L 468.835 716.516 C 468.835 716.516 1089.13 725.939 1089.05 724.765 C 1088.98 723.601 779.835 391.763 776.285 391.456"/> 769 <path id="_nbl_clapper" fill-rule="evenodd" paint-order="stroke fill" 770 transform="matrix(0.95517403, -0.29604402, 0.29604402, 0.95517403, -456.95786825, 588.0155987)" 771 d="M 524.433 1432.53 C 365.242 1947.64 234.251 2372.63 233.351 2376.95 C 232.451 2381.27 231.481 2389.75 231.191 2395.8 C 230.841 2403.26 231.181 2410.12 232.261 2417.13 C 233.131 2422.81 235.451 2432.26 237.411 2438.13 C 239.381 2444 242.971 2452.44 245.401 2456.9 C 247.831 2461.35 251.911 2467.88 254.481 2471.4 C 257.041 2474.92 262.871 2481.46 267.431 2485.94 C 271.991 2490.42 279.241 2496.41 283.551 2499.25 C 287.851 2502.1 295.001 2506.17 299.451 2508.31 C 305.091 2511.02 623.563 2609.86 1356.05 2836.23 C 1932.73 3014.45 2408.35 3161.15 2412.97 3162.23 C 2417.59 3163.32 2425.42 3164.74 2430.37 3165.38 C 2436.04 3166.13 2445.47 3166.35 2455.87 3165.98 C 2466.63 3165.6 2475.74 3164.68 2482.05 3163.34 C 2487.37 3162.21 2495.92 3159.92 2501.05 3158.25 C 2506.18 3156.57 2515.08 3152.96 2520.84 3150.21 C 2526.6 3147.46 2535.6 3142.35 2540.85 3138.86 C 2546.09 3135.37 2554.2 3129.17 2558.87 3125.08 C 2563.55 3121 2570.58 3113.86 2574.51 3109.23 C 2578.43 3104.59 2584.16 3097.03 2587.23 3092.43 C 2590.31 3087.83 2595.04 3079.5 2597.75 3073.93 C 2600.47 3068.36 2604.3 3058.85 2606.28 3052.8
771C 2608.26 3046.75 2741.7 2615.21 2902.81 2093.83 C 3063.93 1572.44 3198.395 1137.842 3198.215 1137.672 C 3198.045 1137.492 2817.49 1343.35 2817.49 1343.35 C 2817.49 1343.35 2528.38 1254.14 2528.24 1254 C 2528.1 1253.86 2909.86 1047.81 2908.62 1047.27 C 2907.38 1046.73 2503.85 922.031 2503.22 921.811 C 2502.59 921.601 2109.55 1124.54 2109.55 1124.54 C 2109.55 1124.54 1767.13 1018.87 1765.62 1018.58 C 1764.11 1018.3 2151.16 813.221 2150.17 812.831 C 2149.18 812.441 1730.94 683.301 1730.12 683.301 C 1729.31 683.301 1363.89 871.901 1354.74 876.651 C 1345.59 881.401 1337.18 885.291 1336.04 885.291 C 1334.9 885.291 1062.03 800.871 1055.5 798.831 C 1048.97 796.791 1044.03 794.771 1044.5 794.341 C 1044.98 793.921 1433.417 591.777 1433.197 590.627 C 1432.977 589.477 881.045 419.444 844.455 408.154 M 943.532 525.271 C 940.872 525.761 935.492 527.121 931.582 528.311 C 927.672 529.501 921.022 532.201 916.812 534.321 C 912.602 536.441 905.722 540.901 901.522 544.241 C 897.322 547.581 891.112 553.791 887.712 558.051 C 884.182 562.481 879.512 570.071 876.812 575.791 C 874.201 581.291 871.321 588.721 870.391 592.291 C 869.471 595.871 868.471 603.971 868.181 610.291 C 867.781 618.801 868.071 624.131 869.331 630.771 C 870.261 635.701 872.341 643.171 873.961 647.361 C 875.572 651.551 879.542 658.981 882.772 663.891 C 886.012 668.781 891.532 675.561 895.052 678.931 C 898.582 682.311 904.952 687.341 909.212 690.131 C 913.472 692.911 920.422 696.521 924.662 698.151 C 928.902 699.781 935.842 701.831 940.082 702.711 C 944.322 703.581 952.322 704.301 957.872 704.301 C 963.422 704.301 971.422 703.581 975.662 702.711 C 979.902 701.831 986.842 699.781 991.082 698.151 C 995.322 696.521 1002.27 692.911 1006.53 690.131 C 1010.79 687.341 1017.16 682.311 1020.67 678.931 C 1024.19 675.561 1029.48 669.171 1032.43 664.741 C 1035.38 660.311 1039.22 653.341 104
7710.96 649.241 C 1042.7 645.151 1044.96 637.741 1045.99 632.791 C 1047.01 627.841 1047.85 619.971 1047.85 615.291 C 1047.85 610.621 1047.39 603.871 1046.83 600.291 C 1046.26 596.721 1044.86 590.871 1043.7 587.291 C 1042.54 583.721 1039.88 577.371 1037.79 573.181 C 1035.7 569.001 1031.26 562.141 1027.92 557.941 C 1024.59 553.741 1018.58 547.701 1014.58 544.521 C 1010.58 541.341 1003.7 536.861 999.292 534.571 C 994.882 532.281 987.242 529.211 982.322 527.761 C 975.302 525.691 970.682 525.041 960.872 524.761 C 953.992 524.561 946.192 524.791 943.532 525.271 M 1097.12 1239.09 C 964.982 1320.88 856.881 1388.02 856.881 1388.3 C 856.891 1388.57 1003.82 1573.48 1183.41 1799.2 L 1509.93 2209.59 L 1427.65 2264.44 L 1345.37 2319.28 L 1152.07 2252.4 C 1045.75 2215.62 958.492 2185.25 958.152 2184.91 C 957.812 2184.57 957.722 2184.15 957.952 2183.96 C 958.182 2183.78 1016.2 2150.17 1086.87 2109.29 C 1157.55 2068.4 1216.6 2034.2 1218.11 2033.29 L 1220.84 2031.64 L 1145.29 1932.72 C 1103.73 1878.31 1069.31 1833.91 1068.79 1834.05 C 1068.26 1834.2 914.962 1929.76 728.101 2046.42 C 541.253 2163.08 387.822 2259.04 387.162 2259.66 C 386.242 2260.52 508.042 2300.95 886.112 2425.3 C 1161.21 2515.77 1386.88 2589.8 1387.61 2589.8 C 1388.34 2589.8 1495.56 2520.05 1625.87 2434.8 C 1759.06 2347.67 1862.63 2279.29 1862.4 2278.63 C 1862.18 2277.99 1715.65 2093.49 1536.79 1868.63 C 1357.93 1643.77 1211.2 1459.31 1210.74 1458.71 C 1210.18 1457.99 1237.69 1439.08 1292.07 1402.83 L 1374.26 1348.04 L 1468.67 1372.7 L 1563.09 1397.36 L 1687.73 1320.88 C 1756.28 1278.81 1813.17 1243.81 1814.16 1243.08 C 1815.7 1241.95 1784.17 1231.67 1577.66 1166 C 1446.6 1124.32 1338.92 1090.25 1338.37 1090.29 C 1337.82 1090.33 1229.26 1157.29 1097.12 1239.09 M 1717.29 1441.36 C 1586.98 1526.65 1480.26 1596.74 1480.14 1597.11 C 1480.01 1597.49 1626.84 1782.47 1806.43 2008.19 L 2132.96 2418.58 L 2050.66 2473.42 L 1968.37 2528.27 L 1874.5 2503.73 C 1822.87 2490.24 1780.1 2479.33 1779.45 2479.5 C 1778.8 2479.66 1721.93 2514.38 1653.07 2556.65 C 1584.21 2598.92 1527.87 2633.8 1527.87 2634.17 C 1527.87 2634.54 1635.09 2668.96 1766.14 2710.66 L 2004.42 2786.47 L 2245.1 2637.64 C 2388.34 2549.07 2485.63 2488.31 2485.38 2487.59 C 2485.15 2486.93 2338.63 2302.43 2159.78 2077.59 C 1980.93 1852.76 1834.22 1668.31 1833.75 1667.71 C 1833.18 1666.98 1860.5 1648.21 1915.06 1611.84 L 1997.23 1557.05 L 2012.3 1562.22 C 2020.59 1565.06 2107.62 1595.15 2205.7 1629.09 C 2303.78 1663.03 2384.03 1691.25 2384.02 1691.8 C 2384.02 1692.35 2325.36 1726.77 2253.66 1768.3 C 2181.96 1809.82 2123.09 1844.11 2122.84 1844.5 C 2122.58 1844.88 2156.34 1889.71 2197.87 1944.11 L 2273.37 2043.01 L 2279.37 2039.25 C 2282.67 2037.17 2436.12 1941.35 2620.37 1826.31 C 2804.62 1711.26 2955.75 1616.77 2956.21 1616.33 C 2956.67 1615.89 2952.84 1614.14 2947.71 1612.45 C 2942.57 1610.76 2717.3 1536.69 2447.11 1447.84 C 2176.92 1358.99 1955.48 1286.3 1955.03 1286.3 C 1954.57 1286.3 1847.59 1356.08 1717.29 1441.36"/> 772 </g> 773 </svg> 774 </div> 775 <span class="fw-bold tracking-tight d-none d-sm-inline">StreamSnip</span> 776 </a> 777 <button class="navbar-toggler border-0 shadow-none d-lg-none d-flex align-items-center justify-content-center" 778 type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" 779 aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation" 780 style="width: 44px; height: 44px; padding: 0;"> 781 <div class="hamburger-icon"> 782 <span></span> 783 <span></span> 784 <span></span> 785 </div> 786 </button> 787 788 <div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent"> 789 <ul class="navbar-nav align-items-lg-center gap-1"> 790 <li class="nav-item"> 791 <a class="nav-link px-3 highlight-link d-flex align-items-center" href="/snips" title="View Clips Feed"> 792 <i class="fa-solid fa-play me-2" aria-hidden="true"></i> Snips 793 </a> 794 </li> 795 796 <li class="nav-item"> 797 <a class="nav-link px-3 highlight-link d-flex align-items-center" href="/get-started" 798 title="How to use StreamSnip"> 799 <i class="fa-solid fa-rocket me-2 d-lg-none" aria-hidden="true"></i> Get Started 800 </a> 801 </li> 802 803 <li class="nav-item dropdown"> 804 <a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="resourcesDropdown" 805 role="button" data-bs-toggle="dropdown" aria-expanded="false" title="More Resources"> 806 <i class="fa-solid fa-folder-open me-2 d-lg-none" aria-hidden="true"></i> Resources 807 </a> 808 <ul class="dropdown-menu dropdown-menu-end glass" aria-labelledby="resourcesDropdown"> 809 <li><a class="dropdown-item" href="/settings#discord"><i class="fa-solid fa-link" 810 aria-hidden="true"></i> Connect 811 Discord</a></li> 812 <li><a class="dropdown-item" 813 href="https://chromewebstore.google.com/detail/stream
813snip/jpnlipcmklpfniaheflfnkomjdkhiani"><i 814 class="fa-solid fa-puzzle-piece" aria-hidden="true"></i> Chrome Extension</a></li> 815 <li><a class="dropdown-item" href="/stats"><i class="fa-solid fa-chart-line" aria-hidden="true"></i> 816 Dashboard Stats</a> 817 </li> 818 <li><a class="dropdown-item" href="/all_games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i> 819 All Games</a></li> 820 <li><a class="dropdown-item" href="/guides"><i class="fa-solid fa-book" aria-hidden="true"></i> 821 Guides</a></li> 822 <li><a class="dropdown-item" href="/ytdlp-builder"><i class="fa-solid fa-terminal" aria-hidden="true"></i> 823 yt-dlp Builder</a></li> 824 <li><a class="dropdown-item" href="/kick_downloader"><img src="/static/kick-icon.png" alt="" style="height:1em;width:auto;vertical-align:-0.15em;"> Kick VOD Downloader</a></li> 825 <li><a class="dropdown-item" href="/valorant-pov"><i class="fa-solid fa-crosshairs" aria-hidden="true"></i> Valorant POV Finder</a></li> 826 <li><a class="dropdown-item" href="/overlays"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> Stream Overlays</a></li> 827 <li><a class="dropdown-item" href="https://streamsnip.cronitorstatus.com" target="_blank"><i 828 class="fa-solid fa-signal" aria-hidden="true"></i> System Status</a></li> 829 </ul> 830 831 </li> 832 833 <li class="nav-item"> 834 <a class="nav-link px-3 d-flex align-items-center" href="#" id="beamer-whats-new" title="What's New"> 835 <i class="fa-solid fa-bell me-2 d-lg-none" aria-hidden="true"></i> <span>What's New</span> 836 </a> 837 </li> 838 839 <li class="nav-item"> 840 <a class="nav-link px-3 d-flex align-items-center" href="https://discord.gg/2XVBWK99Vy" 841 title="Join our Discord Community" aria-label="Discord Community"> 842 <i class="fa-brands fa-discord me-2" aria-hidden="true"></i> <span class="d-lg-none">Discord</span> 843 </a> 844 </li> 845 846 <li class="nav-item"> 847 <a class="nav-link px-3 d-flex align-items-center" href="#" id="report-bug-btn" role="button" 848 title="Report a bug" aria-label="Report a bug" onclick="return false;"> 849 <i class="fa-solid fa-bug me-2" aria-hidden="true"></i> <span class="d-lg-none">Report a bug</span> 850 </a> 851 </li> 852 853 854 855 <li class="nav-item"> 856 <a class="nav-link px-3 d-flex align-items-center" href="#" id="theme_button" onclick="toggleDarkTheme()" 857 title="Toggle Light/Dark Theme" aria-label="Toggle Theme"> 858 <i class="fa-solid fa-moon fa-fw me-2" aria-hidden="true"></i> <span class="d-lg-none">Toggle Theme</span> 859 </a> 860 </li> 861 862 863 <li class="nav-item d-lg-none"> 864 <a class="nav-link px-3 d-flex align-items-center" href="/login?next=/login"> 865 <i class="fa-solid fa-sign-in me-2" aria-hidden="true"></i> Login 866 </a> 867 </li> 868 <li class="nav-item ms-lg-2 d-none d-lg-flex align-items-center"> 869 <a class="btn btn-primary btn-sm rounded-pill px-4" href="/login?next=/login">Login <i 870 class="fa-solid fa-sign-in ms-1" aria-hidden="true"></i></a> 871 </li> 872 873 </ul> 874 </div> 875 876 </nav> 877 <style> 878 /* Modern Hamburger Menu */ 879 .hamburger-icon { 880 width: 22px; 881 height: 16px; 882 position: relative; 883 display: flex; 884 flex-direction: column; 885 justify-content: space-between; 886 } 887 888 .hamburger-icon span { 889 display: block; 890 height: 2px; 891 width: 100%; 892 background-color: var(--color-text); 893 border-radius: 4px; 894 transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease; 895 transform-origin: center center; 896 } 897 898 .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(1) { 899 transform: translateY(7px) rotate(45deg); 900 } 901 902 .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(2) { 903 opacity: 0; 904 transform: scale(0); 905 } 906 907 .navbar-toggler[aria-expanded="true"] .hamburger-icon span:nth-child(3) { 908 transform: translateY(-7px) rotate(-45deg); 909 } 910 911 /* Mobile Navbar Fix */ 912 @media (max-width: 991px) { 913 .navbar-nav { 914 align-items: flex-start !important; 915 padding-top: 1rem; 916 } 917 918 .navbar-nav .nav-item { 919 width: 100%; 920 } 921 922 .navbar-nav .nav-link { 923 width: 100%; 924 padding: 0.75rem 1rem !important; 925 } 926 927 .navbar-collapse { 928 background: var(--glass-bg) !important; 929 backdrop-filter: blur(24px); 930 -webkit-backdrop-filter: blur(24px); 931 border-radius: var(--radius-lg); 932 margin-top: 0.75rem; 933 padding: 0.5rem; 934 border: 1px solid var(--glass-border); 935 box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); 936 } 937 938 body .navbar-nav .dropdown-menu { 939 background: var(--glass-bg) !important; 940 background-color: var(--glass-bg) !important; 941 backdrop-filter: blur(12px) !important; 942 -webkit-backdrop-filter: blur(12px) !important; 943 border: 1px solid var(--glass-border) !important; 944 border-inline-start: 3px solid var(--color-primary) !important; 945 margin-block: 0.5rem !important; 946 margin-inline: 1rem 0 !important; 947 padding: 0.5rem !important; 948 box-shadow: none !important; 949 position: static !important; 950 float: none !important; 951 width: calc(100% - 1rem) !important; 952 min-width: 0 !important; 953 transform: none !important; 954 opacity: 0 !important; 955 visibility: hidden !important; 956 display: none !important; 957 } 958 959 body .navbar-nav .dropdown-menu.show { 960 display: block !important; 961 opacity: 1 !important; 962 visibility: visible !important; 963 } 964 965 .dropdown-item { 966 padding: 0.5rem 0.75rem !important; 967 border-radius: var(--radius-sm); 968 font-size: 0.9rem; 969 } 970 } 971 972 /* Beamer panel uses the nav link as its trigger */ 973 #beamerSelector { 974 display: none !important; 975 border: 1px solid var(--color-primary); 976 } 977 </style> 978 </header> 979 980 <!-- ========= ALERT CONTAINER ========= --> 981 <style> 982 #custom-alerts-container { 983 position: fixed; 984 bottom: 1.5rem; 985 right: 1.5rem; 986 z-index: 99999; 987 display: flex; 988 flex-direction: column; 989 align-items: flex-end; 990 gap: 0.5rem; 991 pointer-events: none; 992 isolation: isolate; 993 } 994 995 @media (max-width: 767.98px) { 996 #custom-alerts-container { 997 right: 50%; 998 transform: translateX(50%); 999 align-items: center; 1000 width: 100%; 1001 padding: 0 1rem; 1002 } 1003 } 1004 1005 .custom-toast { 1006 pointer-events: auto; 1007 min-width: 250px; 1008 max-width: 100%; 1009 background: rgba(255, 255, 255, 0.9); 1010 backdrop-filter: blur(12px); 1011 -webkit-backdrop-filter: blur(12px); 1012 border: 1px solid rgba(255, 255, 255, 0.5); 1013 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); 1014 border-radius: var(--radius-btn); 1015 padding: 1rem 1.25rem; 1016 position: relative; 1017 overflow: visible; 1018 display: flex; 1019 align-items: center; 1020 gap: 0.75rem; 1021 transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); 1022 } 1023 1024 .dark-theme .custom-toast { 1025 background: hsl(var(--accent-h) 26% 12% / 0.85); 1026 border: 1px solid rgba(255, 255, 255, 0.1); 1027 color: var(--color-text); 1028 } 1029 1030 .custom-toast.toast-danger { 1031 border-inline-start: 4px solid var(--color-danger); 1032 } 1033 1034 .custom-toast.toast-success { 1035 border-inline-start: 4px solid var(--color-success); 1036 } 1037 1038 .custom-toast.toast-warning { 1039 border-inline-start: 4px solid var(--color-warning); 1040 } 1041 1042 .custom-toast.toast-info { 1043 border-inline-start: 4px solid var(--color-info); 1044 } 1045 1046 .custom-toast .toast-icon { 1047 font-size: 1.25rem; 1048 flex-shrink: 0; 1049 } 1050 1051 .custom-toast.toast-danger .toast-icon { 1052 color: var(--color-danger); 1053 } 1054 1055 .custom-toast.toast-success .toast-icon { 1056 color: var(--color-success); 1057 } 1058 1059 .custom-toast.toast-warning .toast-icon { 1060 color: var(--color-warning); 1061 } 1062 1063 .custom-toast.toast-info .toast-icon { 1064 color: var(--color-info); 1065 } 1066 1067 .custom-toast .toast-message { 1068 flex-grow: 1; 1069 word-break: break-word; 1070 font-size: 0.95rem; 1071 line-height: 1.4; 1072 } 1073 1074 .custom-toast .toast-action { 1075 flex-shrink: 0; 1076 border: 1px solid var(--glass-border); 1077 background: transparent; 1078 color: inherit; 1079 font: inherit; 1080 font-size: 0.85rem; 1081 font-weight: 600; 1082 padding: 0.35rem 0.75rem; 1083 border-radius: var(--radius-sm, 0.375rem); 1084 cursor: pointer; 1085 transition: background-color var(--transition-fast); 1086 } 1087 1088 .custom-toast .toast-action:hover { 1089 background: var(--overlay-hover, rgba(127, 127, 127, 0.14)); 1090 } 1091 </style>
1092 <div id="custom-alerts-container" role="status" aria-live="polite" aria-atomic="false"></div> 1093 1094 1095 1096 <!-- ========= MAIN CONTENT ========= --> 1097 <main class="content"> 1098<div class="login-page"> 1099 <div class="login-hero-bg"> 1100 <canvas id="login-canvas" aria-hidden="true"></canvas> 1101 </div> 1102 1103 <div class="login-wrapper"> 1104 <div class="login-card glass"> 1105 <div class="login-header"> 1106 <h1>Welcome Back</h1> 1107 </div> 1108 1109 <form action="/login?next=/" method="post"> 1110 <div class="input-group-custom"> 1111 <i class="fas fa-link" aria-hidden="true"></i> 1112 <!-- Placeholder-only fields are invisible to a screen reader once typing 1113 starts, and this one is the whole login. --> 1114 <label for="password" class="visually-hidden">Webhook URL or password</label> 1115 <input type="password" name="password" class="glass-input" placeholder="Webhook URL / Password" 1116 id="password" required autocomplete="current-password" 1117 aria-describedby="password-hint"> 1118 </div> 1119 1120 <div class="remember-row"> 1121 <label class="remember-label"> 1122 <input type="checkbox" id="remember" name="remember" value="remember"> 1123 Remember this session 1124 </label> 1125 </div> 1126 1127 <button type="submit" class="btn btn-primary w-100 py-3" style="font-weight: 600; border-radius: var(--radius-md);"> 1128 Sign In <i class="fas fa-arrow-right ms-2" aria-hidden="true"></i> 1129 </button> 1130 1131 <p class="msg-hint" id="password-hint"> 1132 <i class="fas fa-info-circle me-1" aria-hidden="true"></i> 1133 Your password is the webhook URL registered to your channel. 1134 </p> 1135 </form> 1136 1137 <div class="divider">or</div> 1138 1139 <a href="/login/google?next=/" class="google-btn"> 1140 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"> 1141 <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/> 1142 <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/> 1143 <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/> 1144 <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/> 1145 </svg> 1146 Continue with Google 1147 </a> 1148 1149 <div class="signup-hint"> 1150 New to StreamSnip? <a href="/login/google?next=/">Create an account</a> 1151 </div> 1152 </div> 1153 </div> 1154</div> 1155</main> 1156 1157 <!-- ========= FOOTER ========= --> 1158 <footer class="py-3 border-top border-opacity-10" style="position: relative; z-index: 2;"> 1159 1160 <div class="container"> 1161 <div class="row align-items-center"> 1162 <div class="col-md-6">© 2026 Suraj Bhari</div> 1163 <nav class="col-md-6 text-md-end"> 1164 <a href="/privacy-policy">Privacy Policy</a> | 1165 <a href="/aboutus">About Us</a> | 1166 <a href="/contactus">Contact Us</a> | 1167 <a href="/tnc">Terms & Conditions</a> 1168 </nav> 1169 </div> 1170 <div class="row mt-2" aria-hidden="true"> 1171 <div class="col-12 text-md-end small" style="color: transparent; user-select: none;"> 1172 StreamSnip is original, copyrighted work. Use of any content, code, or design from this site 1173 for AI/ML training, RAG indexing, generative reproduction, or to "rebuild" or "clone" the site 1174 is not permitted. 1175 </div> 1176 </div> 1177 </div> 1178 </footer> 1179 1180 <!-- ========= DOWNLOAD QUEUE ========= --> 1181<style> 1182 #dq-panel { font-family: inherit; } 1183 .dq-row { 1184 border-radius: var(--radius-md); 1185 padding: 0.4rem 0.6rem; 1186 position: relative; 1187 overflow: hidden; 1188 flex-shrink: 0; /* don't compress â let the body scroll instead */ 1189 transition: background 0.2s ease; 1190 } 1191 .dq-row-pending { background: rgba(255,255,255,0.04); } 1192 .dq-row-downloading { background: var(--primary-08); border: 1px solid hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0.2); } 1193 .dq-row-done { background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.2); } 1194 .dq-row-error { background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); } 1195 .dq-label { 1196 font-size: 0.75rem; 1197 color: var(--color-text); 1198 white-space: nowrap; 1199 overflow: hidden; 1200 text-overflow: ellipsis; 1201 max-width: 100%; 1202 } 1203 .dq-sub { font-size: 0.65rem;
1203 margin-top: 1px; } 1204 .dq-detail { font-size: 0.6rem; margin-top: 1px; color: var(--color-text-muted); opacity: 0.7; letter-spacing: 0.02em; } 1205 .dq-actions { 1206 display: flex; 1207 align-items: center; 1208 justify-content: space-between; 1209 gap: 4px; 1210 margin-top: 3px; 1211 } 1212 .dq-btn { 1213 font-size: 0.65rem; 1214 padding: 2px 6px; 1215 border-radius: 4px; 1216 border: none; 1217 cursor: pointer; 1218 line-height: 1.4; 1219 text-decoration: none; 1220 display: inline-flex; 1221 align-items: center; 1222 gap: 3px; 1223 } 1224 .dq-btn-dl { background: rgba(34,197,94,0.2); color: #86efac; } 1225 .dq-btn-dl:hover { background: rgba(34,197,94,0.35); color: #86efac; } 1226 .dq-btn-retry { background: rgba(239,68,68,0.2); color: #fca5a5; } 1227 .dq-btn-retry:hover { background: rgba(239,68,68,0.35); color: #fca5a5; } 1228 .dq-btn-rm { background: none; color: var(--color-text-muted); opacity: 0.6; } 1229 .dq-btn-rm:hover { opacity: 1; } 1230 #dq-body::-webkit-scrollbar { width: 4px; } 1231 #dq-body::-webkit-scrollbar-thumb { background: var(--color-primary); border-radius: 4px; } 1232 /* downloading row indicator */ 1233 .bouncing-gradient { 1234 background: var(--primary-12); 1235 border-inline-start: 3px solid var(--color-primary) !important; 1236 } 1237</style> 1238 1239<div id="dq-panel" style="display:none;position:fixed;bottom:1.5rem;inset-inline-start:1.5rem;z-index:1030;width:260px;"> 1240 <div class="glass" style="border-radius:var(--radius-lg);overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.3);"> 1241 <!-- Header --> 1242 <div id="dq-header" 1243 onclick="DownloadQueue._toggleCollapse()" 1244 style="display:flex;align-items:center;justify-content:space-between;padding:0.55rem 0.75rem;background:var(--glass-bg-subtle);border-bottom:1px solid var(--glass-border);cursor:pointer;user-select:none;"> 1245 <span style="font-size:0.68rem;font-weight:700;letter-spacing:0.05em;color:var(--color-text-muted);text-transform:uppercase;display:flex;align-items:center;gap:6px;"> 1246 <i class="fa-solid fa-download" style="color:var(--color-primary);font-size:0.7rem;" aria-hidden="true"></i> 1247 <span id="dq-title">Downloads</span> 1248 </span> 1249 <div style="display:flex;align-items:center;gap:6px;"> 1250 <a href="/downloads" 1251 onclick="event.stopPropagation();" 1252 style="font-size:0.6rem;color:var(--color-text-muted);padding:1px 4px;opacity:0.65;line-height:1;text-decoration:none;" 1253 title="Open the downloads page">view all</a> 1254 <button id="dq-clear-btn" 1255 onclick="event.stopPropagation();DownloadQueue.clearAll()" 1256 style="display:none;background:none;border:none;cursor:pointer;font-size:0.6rem;color:var(--color-text-muted);padding:1px 4px;opacity:0.65;line-height:1;" 1257 title="Cancel all queued items">clear all</button> 1258 <i id="dq-chevron" class="fa-solid fa-chevron-down" style="font-size:0.6rem;color:var(--color-text-muted);transition:transform 0.2s ease;" aria-hidden="true"></i> 1259 </div> 1260 </div> 1261 <!-- Item list --> 1262 <div id="dq-body" 1263 style="padding:0.4rem;display:flex;flex-direction:column;gap:0.3rem;max-height:180px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:var(--color-primary) transparent;"> 1264 </div> 1265 </div> 1266</div> 1267
1268<script> 1269/* =================== TAB BADGE =================== */ 1270/* One owner of document.title, because two of them fight: whichever captures the 1271 base title second captures the other's "(2) " prefix and bakes it in for good. 1272 Anything that finishes while the tab is hidden counts in here; coming back to 1273 the tab is what clears it. */ 1274window.TabBadge = (() => { 1275 let base = null; 1276 let count = 0; 1277 1278 function _base() { 1279 if (base === null) base = document.title.replace(/^\(\d+\)\s+/, ''); 1280 return base; 1281 } 1282 1283 function set(n) { 1284 count = Math.max(0, n | 0); 1285 document.title = count ? `(${count}) ${_base()}` : _base(); 1286 } 1287 1288 function bump(n) { set(count + (n || 1)); } 1289 function clear() { set(0); } 1290 1291 document.addEventListener('visibilitychange', () => { 1292 if (!document.hidden) clear(); 1293 }); 1294 1295 return { set, bump, clear, get count() { return count; } }; 1296})(); 1297 1298/* =================== SHARE =================== */ 1299/* On a phone, copying a link to the clipboard is the long way round to the thing 1300 people actually want â the OS share sheet, one tap to WhatsApp or a Discord DM. 1301 On desktop the sheet is a worse clipboard (Windows routes it through a half-empty 1302 apps list), so desktop keeps copying. Coarse pointer AND a phone-width screen: 1303 a touchscreen laptop is not a phone, and neither is a tablet in landscape. 1304 Call this from a click handler â navigator.share requires a user gesture. */ 1305window.shareOrCopy = function (url, opts) { 1306 opts = opts || {}; 1307 const onCopied = opts.onCopied || function () { send_alert('Link copied to clipboard!', 'success'); }; 1308 const onFailed = opts.onFailed || function () { send_alert('Failed to copy link.', 'danger'); }; 1309 const isPhone = window.matchMedia('(pointer: coarse) and (max-width: 820px)').matches; 1310 1311 if (isPhone && navigator.share) { 1312 const payload = { url: url }; 1313 if (opts.title) payload.title = opts.title; 1314 // A dismissed sheet rejects with AbortError â that's a decision, not a failure. 1315 return navigator.share(payload).catch(function (err) { 1316 if (err && err.name === 'AbortError') return; 1317 navigator.clipboard.writeText(url).then(onCopied).catch(onFailed); 1318 }); 1319 } 1320 return navigator.clipboard.writeText(url).then(onCopied).catch(onFailed); 1321}; 1322 1323/* =================== DOWNLOAD QUEUE =================== */ 1324window.DownloadQueue = (() => { 1325 const STORAGE_KEY = 'ss_download_queue'; 1326 const COLLAPSE_KEY = 'ss_dq_collapsed'; 1327 const NOTIFY_KEY = 'ss_dq_downloads_started'; 1328 1329 /* A cut can take minutes and people tab away; the badge in the corner only 1330 helps someone already looking at it. Ask for notification permission on the 1331 second download rather than the first â one download is not yet a habit, and 1332 a prompt on the very first click reads as a shakedown (Chrome also holds a 1333 denial against the origin for good). The ask rides the click that triggered 1334 it, so Firefox's user-gesture requirement is satisfied. */ 1335 function _maybeAskNotify() { 1336 if (!('Notification' in window) || Notification.permission !== 'default') return; 1337 let started = 0; 1338 try { started = parseInt(localStorage.getItem(NOTIFY_KEY) || '0', 10) || 0; } catch { return; } 1339 started++; 1340 try { localStorage.setItem(NOTIFY_KEY, String(started)); } catch {} 1341 if (started < 2) return; 1342 try { Notification.requestPermission(); } catch {} 1343 } 1344 1345 function _notify(item) { 1346 if (!document.hidden) return; 1347 TabBadge.bump(); 1348 if (!('Notification' in window) || Notification.permission !== 'granted') return; 1349 try { 1350 const n = new Notification( 1351 item.status === 'done' ? 'Your clip is ready' : 'Clip download failed', 1352 { 1353 body: item.label || '', 1354 icon: '/static/logo.png', 1355 // Same clip re-firing replaces its own notification instead of stacking. 1356 tag: 'ss-dq-' + item.id, 1357 } 1358 ); 1359 n.onclick = () => { window.focus(); n.close(); }; 1360 } catch {} 1361 } 1362 1363 function _load() { 1364 try { 1365 const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); 1366 const SIX_HOURS = 6 * 60 * 60 * 1000; 1367 const now = Date.now(); 1368 const fresh = raw.filter(item => { 1369 if ((item.status === 'done' || item.status === 'error') && item.doneAt) { 1370 return now - item.doneAt < SIX_HOURS; 1371 } 1372 return true; 1373 }); 1374 if (fresh.length !== raw.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)); 1375 return fresh; 1376 } catch { return []; } 1377 } 1378 1379 function _save(queue) { 1380 localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); 1381 } 1382 1383 function _esc(s) { 1384 return String(s) 1385 .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 1386 } 1387 1388 function _isCollapsed() { 1389 return localStorage.getItem(COLLAPSE_KEY) === 'true'; 1390 } 1391 1392 function _toggleCollapse() { 1393 const next = !_isCollapsed(); 1394 localStorage.setItem(COLLAPSE_KEY, next); 1395 _applyCollapse(next); 1396 } 1397 1398 function _applyCollapse(collapsed) { 1399 const body = document.getElementById('dq-body'); 1400 const chevron = document.getElementById('dq-chevron'); 1401 if (body) body.style.display = collapsed ? 'none' : 'flex'; 1402 if (chevron) chevron.style.transform = collapsed ? 'rotate(-90deg)' : ''; 1403 } 1404 1405 function _formatDetail(item) { 1406 const parts = []; 1407 // Use stored human-readable label when available (e.g. "720p · MP4") 1408 if (item.detail) { 1409 parts.push(item.detail); 1410 } else { 1411 // Fallback for items added before detail was stored
1412 try { 1413 const p = new URLSearchParams(item.qs); 1414 const fid = p.get('format_id') || ''; 1415 const ext = (p.get('ext') || '').toUpperCase(); 1416 if (fid.startsWith('custom-')) parts.push(fid.replace('custom-', '') + 'p'); 1417 else if (fid) parts.push(fid); 1418 if (ext) parts.push(ext); 1419 } catch {} 1420 } 1421 // Always append clip duration when start/end are set 1422 try { 1423 const p = new URLSearchParams(item.qs); 1424 const t1 = parseInt(p.get('start_time')), t2 = parseInt(p.get('end_time')); 1425 if (!isNaN(t1) && !isNaN(t2) && t2 > t1) { 1426 const dur = t2 - t1; 1427 const mm = String(Math.floor(dur / 60)).padStart(2, '0'); 1428 const ss = String(dur % 60).padStart(2, '0'); 1429 parts.push(`${mm}:${ss} clip`); 1430 } 1431 } catch {} 1432 return parts.join(' · '); 1433 } 1434 1435 function _render() { 1436 const queue = _load(); 1437 const panel = document.getElementById('dq-panel'); 1438 const body = document.getElementById('dq-body'); 1439 const title = document.getElementById('dq-title'); 1440 if (!panel || !body) return; 1441 1442 panel.style.display = queue.length === 0 ? 'none' : 'block'; 1443 if (title) title.textContent = `Downloads (${queue.length})`; 1444 const clearBtn = document.getElementById('dq-clear-btn'); 1445 if (clearBtn) clearBtn.style.display = queue.length > 0 ? 'inline' : 'none'; 1446 1447 body.innerHTML = ''; 1448 // Sort: downloading first, then pending, then error, then done (done: most recent on top) 1449 const _statusOrder = { downloading: 0, pending: 1, error: 2, done: 3 }; 1450 const sorted = queue 1451 .map((item, i) => ({ item, i })) 1452 .sort((a, b) => { 1453 const diff = (_statusOrder[a.item.status] ?? 9) - (_statusOrder[b.item.status] ?? 9); 1454 if (diff !== 0) return diff; 1455 if (a.item.status === 'done') return b.i - a.i; // newest done first 1456 return a.i - b.i; 1457 }) 1458 .map(({ item }) => item); 1459 sorted.forEach(item => { 1460 const row = document.createElement('div'); 1461 row.className = `dq-row dq-row-${item.status}`; 1462 row.id = `dq-row-${CSS.escape(item.id)}`; 1463 1464 const labelEl = document.createElement('div'); 1465 labelEl.className = 'dq-label'; 1466 labelEl.title = item.label; 1467 1468 if (item.status === 'pending') { 1469 labelEl.textContent = item.label; 1470 const actions = document.createElement('div'); 1471 actions.className = 'dq-actions'; 1472 1473 const sub = document.createElement('div'); 1474 sub.className = 'dq-sub'; 1475 sub.style.color = 'var(--color-text-muted)'; 1476 sub.textContent = 'queued'; 1477 1478 const rmBtn = document.createElement('button'); 1479 rmBtn.className = 'dq-btn dq-btn-rm'; 1480 rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>'; 1481 rmBtn.title = 'Cancel'; 1482 rmBtn.setAttribute('aria-label', 'Cancel ' + (item.label || 'this download')); 1483 rmBtn.onclick = () => remove(item.id); 1484 1485 actions.appendChild(sub); 1486 actions.appendChild(rmBtn); 1487 row.appendChild(labelEl); 1488 const det0 = _formatDetail(item); 1489 if (det0) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det0; row.appendChild(d); } 1490 row.appendChild(actions); 1491 1492 } else if (item.status === 'downloading') { 1493 row.classList.add('bouncing-gradient'); 1494 labelEl.textContent = item.label; 1495 const actions = document.createElement('div'); 1496 actions.className = 'dq-actions'; 1497 1498 const sub = document.createElement('div'); 1499 sub.className = 'dq-sub'; 1500 sub.style.color = 'var(--color-primary)'; 1501 sub.textContent = 'downloadingâ¦'; 1502 1503 const rmBtn = document.createElement('button'); 1504 rmBtn.className = 'dq-btn dq-btn-rm'; 1505 rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>'; 1506 rmBtn.title = 'Cancel'; 1507 rmBtn.setAttribute('aria-label', 'Cancel ' + (item.label || 'this download')); 1508 rmBtn.onclick = () => remove(item.id); 1509 1510 actions.appendChild(sub); 1511 actions.appendChild(rmBtn); 1512 row.appendChild(labelEl); 1513 const det1 = _formatDetail(item); 1514 if (det1) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det1; row.appendChild(d); } 1515 row.appendChild(actions); 1516 1517 } else if (item.status === 'done') { 1518 labelEl.innerHTML = '<i class="fa-solid fa-check me-1" style="color:#4ade80"></i>' + _esc(item.label); 1519 const actions = document.createElement('div'); 1520 actions.className = 'dq-actions'; 1521 1522 const sub = document.createElement('div'); 1523 sub.className = 'dq-sub'; 1524 sub.style.color = '#4ade80'; 1525 sub.textContent = 'ready'; 1526 1527 const dlBtn = document.createElement('a'); 1528 dlBtn.href = `/video/${item.id}?${item.qs}`; 1529 dlBtn.className = 'dq-btn dq-btn-dl'; 1530 dlBtn.innerHTML = '<i class="fa-solid fa-download" aria-hidden="true"></i>'; 1531 dlBtn.title = 'Save file'; 1532 dlBtn.setAttribute('aria-label', 'Save ' + (item.label || 'this download')); 1533 1534 const rmBtn = document.createElement('button'); 1535 rmBtn.className = 'dq-btn dq-btn-rm'; 1536 rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>'; 1537 rmBtn.title = 'Remove from the list'; 1538 rmBtn.setAttribute('aria-label', 'Remove ' + (item.label || 'this download') + ' from the list'); 1539 rmBtn.onclick = () => remove(item.id); 1540 1541 actions.appendChild(sub); 1542 actions.appendChild(dlBtn); 1543 actions.appendChild(rmBtn); 1544 row.appendChild(labelEl); 1545 const det2 = _formatDetail(item); 1546 if (det2) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det2; row.appendChild(d); } 1547 row.appendChild(actions); 1548 1549 } else if (item.status === 'error') { 1550 labelEl.innerHTML = '<i class="fa-solid fa-xmark me-1" style="color:#f87171"></i>' + _esc(item.label); 1551 const actions = document.createElement('div'); 1552 actions.className = 'dq-actions'; 1553 1554 const sub = document.createElement('div'); 1555 sub.className = 'dq-sub'; 1556 sub.style.color = '#f87171'; 1557 sub.textContent = (item.error || 'Error').substring(0, 38); 1558 1559 const retryBtn = document.createElement('button'); 1560 retryBtn.className = 'dq-btn dq-btn-retry'; 1561 retryBtn.innerHTML = '<i class="fa-solid fa-rotate-right" aria-hidden="true"></i> retry'; 1562 retryBtn.setAttribute('aria-label', 'Retry ' + (item.label || 'this download')); 1563 retryBtn.onclick = () => retry(item.id); 1564 1565 const rmBtn = document.createElement('button'); 1566 rmBtn.className = 'dq-btn dq-btn-rm'; 1567 rmBtn.innerHTML = '<i class="fa-solid fa-xmark" aria-hidden="true"></i>'; 1568 rmBtn.title = 'Remove from the list'; 1569 rmBtn.setAttribute('aria-label', 'Remove ' + (item.label || 'this download') + ' from the list'); 1570 rmBtn.onclick = () => remove(item.id); 1571 1572 actions.appendChild(sub); 1573 actions.appendChild(retryBtn); 1574 actions.appendChild(rmBtn); 1575 row.appendChild(labelEl); 1576 const det3 = _formatDetail(item); 1577 if (det3) { const d = document.createElement('div'); d.className = 'dq-detail'; d.textContent = det3; row.appendChild(d); } 1578 row.appendChild(actions); 1579 } 1580 1581 body.appendChild(row); 1582 }); 1583 1584 _applyCollapse(_isCollapsed()); 1585 } 1586 1587 /* ---------- public mutators ---------- */ 1588 1589 function add(id, label, qs, detail) { 1590 const queue = _load(); 1591 // One row per clip id â _settle/_pollJob/_triggerDownload all key off the 1592 // (assumed-unique) id. Re-download an already-finished clip and we must 1593 // re-activate its existing row, not push a duplicate: a duplicate id makes 1594 // _settle update the wrong row, leaving the new one stuck 'downloading' and 1595 // re-firing the save dialog on every page load. 1596 const existing = queue.find(i => i.id === id); 1597 if (existing) { 1598 if (existing.status === 'pending' || existing.status === 'downloading') return; 1599 existing.status = 'pending'; 1600 existing.label = label; 1601 existing.qs = qs; 1602 existing.detail = detail || ''; 1603 delete existing.error; 1604 delete existing.doneAt; 1605 } else { 1606 queue.push({ id, label, qs, detail: detail || '', status: 'pending' }); 1607 } 1608 if (typeof gtag === 'function') gtag('event', 'clip_download', { clip_id: id, source: 'youtube' }); 1609 _maybeAskNotify(); 1610 _save(queue); 1611 _render(); 1612 _kickProcessor(); 1613 } 1614 1615 function remove(id) { 1616 _save(_load().filter(i => i.id !== id)); 1617 _render(); 1618 } 1619 1620 function clearAll() { 1621 _save([]); 1622 _render(); 1623 } 1624 1625 function retry(id) { 1626 const queue = _load(); 1627 const item = queue.find(i => i.id === id); 1628 if (item) { item.status = 'pending'; delete item.error; } 1629 _save(queue); 1630 _render(); 1631 _kickProcessor(); 1632 } 1633 1634 /* ---------- processor ---------- */ 1635 1636 let _processing = false;
1637 1638 function _kickProcessor() { 1639 if (!_processing) _process(); 1640 } 1641 1642 function _process() { 1643 const queue = _load(); 1644 const next = queue.find(i => i.status === 'pending'); 1645 if (!next) { _processing = false; return; } 1646 _processing = true; 1647 next.status = 'downloading'; 1648 _save(queue); 1649 _render(); 1650 document.dispatchEvent(new CustomEvent('dq:downloading', { detail: { id: next.id, qs: next.qs } })); 1651 1652 fetch(`/download/${next.id}?${next.qs}`, { method: 'POST' }) 1653 .then(res => res.json()) 1654 .then(({ job_id }) => _pollJob(job_id, next)) 1655 .catch(() => _settle(next, 'error', 'Could not start download')) 1656 .finally(() => { _processing = false; _kickProcessor(); }); 1657 } 1658 1659 function _pollJob(jobId, item) { 1660 return new Promise(resolve => { 1661 const tick = () => { 1662 // Stop polling if the item was removed from the queue mid-flight, 1663 // so a stuck/long-lived job doesn't leave an orphaned 2s loop running. 1664 if (!_load().some(i => i.id === item.id)) { resolve(); return; } 1665 fetch(`/jobs/${jobId}`) 1666 .then(r => r.json()) 1667 .then(d => { 1668 if (d.status === 'succeeded') { _settle(item, 'done'); _triggerDownload(item); resolve(); } 1669 else if (d.status === 'failed' || d.status === 'unknown' || d.status === 'forbidden') 1670 { _settle(item, 'error', d.error || 'Download failed'); resolve(); } 1671 else setTimeout(tick, 2000); // queued | doing 1672 }) 1673 .catch(() => { _settle(item, 'error', 'Lost connection to job'); resolve(); }); 1674 }; 1675 tick(); 1676 }); 1677 } 1678 1679 function _settle(next, status, error) { 1680 const q = _load(); 1681 const item = q.find(i => i.id === next.id); 1682 if (!item) return; 1683 item.status = status; 1684 if (error) item.error = error; 1685 item.doneAt = Date.now(); 1686 _save(q); 1687 _render(); 1688 _notify(item); 1689 document.dispatchEvent(new CustomEvent('dq:settled', { detail: { id: item.id, status: item.status } })); 1690 } 1691 1692 function _triggerDownload(item) { 1693 const a = document.createElement('a'); 1694 a.href = `/video/${item.id}?${item.qs}`; 1695 a.style.display = 'none'; 1696 document.body.appendChild(a); 1697 a.click(); 1698 document.body.removeChild(a); 1699 } 1700 1701 /* ---------- init ---------- */ 1702 1703 function _init() { 1704 const queue = _load(); 1705 // Self-heal duplicate clip-id rows the old add() left in storage â they broke 1706 // _settle's find-by-id and stranded rows on 'downloading' (re-prompting a save 1707 // on every load). Collapse to one row per id (keep the latest), then reset any 1708 // row left 'downloading' by a previous page load back to 'pending'. 1709 const byId = new Map(); 1710 queue.forEach(item => byId.set(item.id, item)); 1711 const deduped = [...byId.values()]; 1712 let changed = deduped.length !== queue.length; 1713 deduped.forEach(item => { 1714 if (item.status === 'downloading') { item.status = 'pending'; changed = true; } 1715 }); 1716 if (changed) _save(deduped); 1717 _render(); 1718 _kickProcessor(); 1719 } 1720 1721 return { add, remove, retry, clearAll, _render, _load, _save, _toggleCollapse, _applyCollapse, _kickProcessor, _process, _init }; 1722})(); 1723 1724document.addEventListener('DOMContentLoaded', () => DownloadQueue._init()); 1725</script>
1725 1726 1727 <!-- ========= SCRIPTS ========= --> 1728
1728<script src="https://code.jquery.com/jquery-3.7.1.min.js" 1729 integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous" defer></script>
1729 1730 1731
1731<script 1732 src="https://cdn.jsdelivr.net/gh/xcash/[email protected]/dist/latest/bootstrap-autocomplete.min.js" 1733 defer></script>
1733 1734
1734<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" 1735 integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous" 1736 defer></script>
1736 1737
1737<script src="/static/dropdown.js?v=ccddf8d66c" defer></script>
1737 1738
1738<script defer> 1739 /* A nav menu's frosted blur is a no-op while it lives inside .glass-nav: the header 1740 is a backdrop root (backdrop-filter + view-transition-name), so a descendant's 1741 backdrop-filter has no page pixels to sample. Lift the menu out to <body> while 1742 it's open and anchor it to the toggle â the header is fixed, so that anchor never 1743 moves and no scroll repositioning is needed. Expanded navbar only; collapsed, the 1744 menu renders inline as an accordion and must stay where it is. */ 1745 (function () { 1746 const desktop = window.matchMedia('(min-width: 992px)'); 1747 const pairs = []; 1748 1749 function place(menu, toggle) { 1750 const rect = toggle.getBoundingClientRect(); 1751 menu.style.setProperty('top', `${rect.bottom + 8}px`, 'important'); 1752 menu.style.setProperty('right', `${document.documentElement.clientWidth - rect.right}px`, 'important'); 1753 } 1754 1755 for (const toggle of document.querySelectorAll('header.glass-nav [data-bs-toggle="dropdown"]')) { 1756 const menu = toggle.parentElement.querySelector('.dropdown-menu'); 1757 if (!menu) continue; 1758 const home = menu.parentElement; 1759 pairs.push([menu, toggle]); 1760 1761 toggle.addEventListener('show.bs.dropdown', () => { 1762 if (!desktop.matches) return; 1763 document.body.appendChild(menu); 1764 menu.classList.add('dropdown-menu-detached'); 1765 place(menu, toggle); 1766 }); 1767 1768 toggle.addEventListener('hidden.bs.dropdown', () => { 1769 if (!menu.classList.contains('dropdown-menu-detached')) return; 1770 menu.classList.remove('dropdown-menu-detached'); 1771 menu.style.removeProperty('top'); 1772 menu.style.removeProperty('right'); 1773 home.appendChild(menu); 1774 }); 1775 } 1776 1777 window.addEventListener('resize', () => { 1778 for (const [menu, toggle] of pairs) { 1779 if (!menu.classList.contains('dropdown-menu-detached')) continue; 1780 // Crossing into the collapsed navbar mid-open: close it so it reattaches. 1781 if (desktop.matches) place(menu, toggle); 1782 else bootstrap.Dropdown.getInstance(toggle)?.hide(); 1783 } 1784 }); 1785 })(); 1786 </script>
1786 1787
1787<script> 1788 var beamer_config = { 1789 product_id: 'MzmSqYwo78692', 1790 user_id: "anonymous", 1791 selector: '#beamer-whats-new', 1792 display: "light" 1793 }; 1794 </script>
1794 1795
1795<script type="text/javascript" src="https://app.getbeamer.com/js/beamer-embed.js" defer="defer"></script>
1795 1796
1796<script defer> 1797 /* =================== THEME =================== */ 1798 const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); 1799 const storageKey = 'darkTheme'; 1800 var isHome = false; 1801 // iOS fixed background causes scroll jank â use scroll attachment instead 1802 const _isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || 1803 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 1804 function setTheme(dark) { 1805 document.documentElement.classList.toggle('dark-theme', dark); 1806 document.body.classList.toggle('dark-theme', dark); 1807 1808 // No colors are hardcoded here. Toggling the `.dark-theme` class (above) swaps the 1809 // token values in :root / .dark-theme, and `html, body { background: var(--color-canvas) }` 1810 // repaints the page background. Single source of truth = the CSS tokens in style.css. 1811 const themeBtnIcon = dark ? 'fa-moon' : 'fa-sun'; 1812 document.getElementById('theme_button').innerHTML = `<i class="fa-solid ${themeBtnIcon} fa-fw me-2" aria-hidden="true"></i><span class="d-lg-none">Toggle Theme</span>`; 1813 document.body.style.backgroundAttachment = ''; 1814 1815 1816 // navbar / siteâicon color tweaks 1817 const navbar = document.getElementById('navbar_id'); 1818 navbar.classList.toggle('navbar-dark', dark); 1819 navbar.classList.toggle('navbar-light', !dark); 1820 1821 1822 document.querySelectorAll('.site_icon').forEach(icon => { 1823 icon.src = dark ? '/static/logo.svg' : '/static/logo-grey.svg'; 1824 }); 1825 1826 // bulk toggle of darkâtheme utility class 1827 const selectors = ['.navbar-collapse', '.form-control', '.table', 'a', 'select', 'ul', '.card', '.dropdown-item', 'thead', 'footer', '.alert']; 1828 selectors.forEach(sel => document.querySelectorAll(sel).forEach(el => el.classList.toggle('dark-theme', dark))); 1829 1830 // Force mobile navbar theme-sync (hard-lock) - ONLY ON MOBILE 1831 if (window.innerWidth <= 991) { 1832 const collapse = document.querySelector('.navbar-collapse'); 1833 if (collapse) { 1834 const bg = 'var(--color-bg)'; 1835 const txt = 'var(--color-text)'; 1836 collapse.style.setProperty('background', bg, 'important'); 1837 collapse.querySelectorAll('.nav-link').forEach(link => { 1838 link.style.setProperty('color', txt, 'important'); 1839 }); 1840 } 1841 } else { 1842 // Reset on desktop 1843 const collapse = document.querySelector('.navbar-collapse'); 1844 if (collapse) { 1845 collapse.style.removeProperty('background'); 1846 collapse.querySelectorAll('.nav-link').forEach(link => { 1847 link.style.removeProperty('color'); 1848 }); 1849 } 1850 } 1851 1852 // google signâin buttons (if present) 1853 document.querySelectorAll('.google_sign_in').forEach(img => { 1854 const darkSrc = img.id === 'sign_in' ? '/static/sign_in_dark.png' : '/static/sign_up_dark.png'; 1855 const lightSrc = img.id === 'sign_in' ? '/static/sign_in_light.png' : '/static/sign_up_light.png'; 1856 img.src = dark ? darkSrc : lightSrc; 1857 }); 1858 localStorage.setItem(storageKey, dark); 1859 if (typeof setThemeCallback === 'function') { 1860 setThemeCallback(dark); 1861 } 1862 1863 // Update Beamer theme on-the-fly 1864 if (window.Beamer && typeof Beamer.update === 'function') { 1865 Beamer.update({ display: dark ? 'dark' : 'light' }); 1866 } 1867 } 1868 1869 function initTheme() { 1870 const stored = localStorage.getItem(storageKey); 1871 const dark = stored ? stored === 'true' : prefersDark.matches || !window.matchMedia; // default dark on small devices 1872 1873 setTheme(dark); 1874 } 1875 1876 function toggleDarkTheme() { 1877 setTheme(!document.body.classList.contains('dark-theme')); 1878 } 1879 function timeSince(time) { 1880 const current = new Date(); 1881 const elapsed = (current - new Date(time)) / 1000; // seconds 1882 const secPerMinute = 60; 1883 const secPerHour = secPerMinute * 60; 1884 const secPerDay = secPerHour * 24; 1885 const secPerMonth = secPerDay * 30; 1886 const secPerYear = secPerDay * 365; 1887 1888 if (elapsed < secPerMinute) { 1889 return Math.floor(elapsed) === 1 1890 ? "1 second ago" 1891 : `${Math.floor(elapsed)} seconds ago`; 1892 } else if (elapsed < secPerHour) { 1893 return Math.floor(elapsed / 60) === 1 1894 ? "1 minute ago" 1895 : `${Math.floor(elapsed / 60)} minutes ago`; 1896 } else if (elapsed < secPerDay) { 1897 return Math.floor(elapsed / 3600) === 1 1898 ? "1 hour ago" 1899 : `${Math.floor(elapsed / 3600)} hours ago`; 1900 } else if (elapsed < secPerMonth) { 1901 return Math.floor(elapsed / 86400) === 1 1902 ? "~ 1 day ago" 1903 : `~ ${Math.floor(elapsed / 86400)} days ago`; 1904 } else if (elapsed < secPerYear) { 1905 return Math.floor(elapsed / 2592000) === 1 1906 ? "~ 1 month ago" 1907 : `~ ${Math.floor(elapsed / 2592000)} months ago`; 1908 } else { 1909 return Math.floor(elapsed / 31536000) === 1 1910 ? "~ 1 year ago" 1911 : `~ ${Math.floor(elapsed / 31536000)} years ago`; 1912 } 1913 } 1914 1915 /* =================== MINI-STATS =================== */ 1916 let mini_stats_update_interval = null; 1917 1918 async function fetchMiniStats() { 1919 if (document.hidden) return; 1920 const url = "/mini_stats"; 1921 let res;
1922 try { res = await fetch(url); } catch { return; } 1923 if (!res.ok) return; 1924 1925 const { last_clip, today_count } = await res.json(); 1926 1927 // Update UI immediately on fetch 1928 updateMiniStats(last_clip, today_count); 1929 1930 if (mini_stats_update_interval) { 1931 clearInterval(mini_stats_update_interval); 1932 } 1933 1934 mini_stats_update_interval = setInterval(() => { 1935 updateMiniStats(last_clip, today_count); 1936 }, 5 * 60 * 1000); // 5 minutes 1937 1938 isFirstLoad = false; 1939 return { last_clip, today_count }; 1940 } 1941 1942 function updateMiniStats(last_clip, today_count) { 1943 const todayEl = document.getElementById("today_count"); 1944 const lastEl = document.getElementById("last_clip_time"); 1945 const statsPill = document.getElementById("stats-pill"); 1946 1947 if (todayEl) { 1948 todayEl.innerHTML = `<i class='fa-solid fa-fire-pulse me-2'></i> <strong style="font-variant-numeric: tabular-nums;">${today_count}</strong> Clips Captured in 24h`; 1949 } 1950 1951 if (lastEl) { 1952 if (last_clip) { 1953 const ts = timeSince(last_clip.time); 1954 lastEl.textContent = `Latest Clip: ${ts}`; 1955 } else { 1956 lastEl.textContent = `Start the first clip!`; 1957 } 1958 } 1959 1960 // Update stats pill visibility if it exists on the page (home page) 1961 if (statsPill) { 1962 if (today_count > 0) { 1963 statsPill.style.setProperty("display", "inline-flex", "important"); 1964 } else { 1965 statsPill.style.setProperty("display", "none", "important"); 1966 } 1967 } 1968 } 1969 1970 1971 function showMembershipAlerts() { 1972 1973 1974 1975 1976 1977 } 1978 /* =================== ALERTS =================== */ 1979 // `action` â optional { label, onClick } that renders a button inside the toast, 1980 // for offering an undo instead of making the user hunt for the reverse operation. 1981 function send_alert(message, level, href, duration = 5000, action = null) { 1982 // Ensure a default level if none is provided 1983 if (!level) { 1984 level = 'info'; 1985 } 1986 1987 // Create or get the custom alerts container 1988 let customAlertsContainer = document.getElementById('custom-alerts-container'); 1989 if (!customAlertsContainer) { 1990 customAlertsContainer = document.createElement('div'); 1991 customAlertsContainer.id = 'custom-alerts-container'; 1992 customAlertsContainer.setAttribute('role', 'status'); 1993 customAlertsContainer.setAttribute('aria-live', 'polite'); 1994 customAlertsContainer.setAttribute('aria-atomic', 'false'); 1995 document.body.appendChild(customAlertsContainer); 1996 } 1997 1998 // Determine the icon based on the alert level 1999 let iconClass = ''; 2000 switch (level) { 2001 case 'danger': 2002 iconClass = 'fa-solid fa-circle-exclamation'; 2003 break; 2004 case 'success': 2005 iconClass = 'fa-solid fa-circle-check'; 2006 break; 2007 case 'warning': 2008 iconClass = 'fa-solid fa-triangle-exclamation'; 2009 break; 2010 case 'info': 2011 iconClass = 'fa-solid fa-circle-info'; 2012 break; 2013 default: 2014 iconClass = 'fa-solid fa-bell'; 2015 break; 2016 } 2017 2018 // Create the alert element 2019 const alertElement = document.createElement('div'); 2020 alertElement.className = `alert custom-toast toast-${level} m-0`; 2021 2022 let messageBody = message; 2023 if (href) { 2024 messageBody = `<a href="${href}" class="alert-link text-decoration-none">${message}</a>`; 2025 } 2026 2027 alertElement.innerHTML = ` 2028 <i class="${iconClass} toast-icon" aria-hidden="true"></i> 2029 <div class="toast-message">${messageBody}</div> 2030 `; 2031 2032 // Apply initial animation styles for entry 2033 alertElement.style.opacity = '0'; 2034 alertElement.style.transform = 'translateY(20px)'; 2035 2036 // Append the new alert to the custom container 2037 customAlertsContainer.appendChild(alertElement); 2038 2039 // Trigger the entry animation after appending 2040 setTimeout(() => { 2041 alertElement.style.opacity = '1'; 2042 alertElement.style.transform = 'translateY(0) translateX(0)'; 2043 }, 10); 2044 2045 const dismissAlert = () => { 2046 alertElement.style.opacity = '0'; 2047 alertElement.style.transform = 'scale(0.9)'; 2048 setTimeout(() => { 2049 if (alertElement.parentNode) { 2050 alertElement.parentNode.removeChild(alertElement); 2051 } 2052 }, 400); // Wait for transition 2053 }; 2054
2055 let dismissTimeout; 2056 2057 const startDismissTimer = () => { 2058 dismissTimeout = setTimeout(() => { 2059 dismissAlert(); 2060 }, duration); 2061 }; 2062 2063 startDismissTimer(); 2064 2065 if (action && action.label) { 2066 const actionBtn = document.createElement('button'); 2067 actionBtn.type = 'button'; 2068 actionBtn.className = 'toast-action'; 2069 actionBtn.textContent = action.label; 2070 actionBtn.addEventListener('click', () => { 2071 clearTimeout(dismissTimeout); 2072 dismissAlert(); 2073 if (typeof action.onClick === 'function') action.onClick(); 2074 }); 2075 alertElement.appendChild(actionBtn); 2076 } 2077 2078 // Desktop hover pause 2079 alertElement.addEventListener('mouseenter', () => { 2080 clearTimeout(dismissTimeout); 2081 alertElement.style.opacity = '1'; 2082 alertElement.style.transform = 'translateY(0) translateX(0)'; 2083 }); 2084 2085 alertElement.addEventListener('mouseleave', () => { 2086 startDismissTimer(); 2087 }); 2088 2089 // Swipe to dismiss logic for mobile 2090 let startX = 0; 2091 let currentX = 0; 2092 2093 alertElement.addEventListener('touchstart', (e) => { 2094 startX = e.touches[0].clientX; 2095 alertElement.style.transition = 'none'; // Disable transition for 1:1 finger tracking 2096 clearTimeout(dismissTimeout); 2097 }, { passive: true }); 2098 2099 alertElement.addEventListener('touchmove', (e) => { 2100 currentX = e.touches[0].clientX; 2101 const diffX = currentX - startX; 2102 // Move the toast with finger 2103 alertElement.style.transform = `translateX(${diffX}px)`; 2104 // Fade out slightly as it moves away 2105 alertElement.style.opacity = Math.max(0, 1 - Math.abs(diffX) / window.innerWidth); 2106 }, { passive: true }); 2107 2108 alertElement.addEventListener('touchend', (e) => { 2109 const diffX = currentX - startX; 2110 // Restore transition 2111 alertElement.style.transition = 'opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)'; 2112 2113 if (Math.abs(diffX) > 80) { 2114 // Swipe threshold reached, fly off screen and dismiss 2115 const direction = diffX > 0 ? 1 : -1; 2116 alertElement.style.transform = `translateX(${direction * window.innerWidth}px)`; 2117 alertElement.style.opacity = '0'; 2118 setTimeout(() => { 2119 if (alertElement.parentNode) { 2120 alertElement.parentNode.removeChild(alertElement); 2121 } 2122 }, 400); 2123 } else { 2124 // Snap back 2125 alertElement.style.transform = 'translateY(0) translateX(0)'; 2126 alertElement.style.opacity = '1'; 2127 startDismissTimer(); // Resume dismiss timer 2128 } 2129 }); 2130 } 2131 2132 2133 /* =================== INIT =================== */ 2134 document.addEventListener('DOMContentLoaded', () => { 2135 initTheme(); 2136 fetchMiniStats(); 2137 let miniStatsTimer = setInterval(fetchMiniStats, 60_000); 2138 2139 document.addEventListener('visibilitychange', () => { 2140 if (document.hidden) { 2141 clearInterval(miniStatsTimer); 2142 miniStatsTimer = null; 2143 } else { 2144 fetchMiniStats(); 2145 miniStatsTimer = miniStatsTimer || setInterval(fetchMiniStats, 60_000); 2146 } 2147 }); 2148 if (typeof showMembershipAlerts === 'function') { 2149 showMembershipAlerts(); 2150 } 2151 2152 2153 2154 // Navbar logo hover clap 2155 (function() { 2156 var wrap = document.getElementById('site_icon'); 2157 var svg = document.getElementById('_nbl_svg'); 2158 var stick = document.getElementById('_nbl_stick'); 2159 if (!wrap || !svg || !stick) return; 2160 var busy = false; 2161 wrap.addEventListener('mouseenter', function() { 2162 if (busy) return; 2163 busy = true; 2164 stick.classList.add('_nbl_play'); 2165 svg.classList.add('_nbl_play'); 2166 setTimeout(function() { 2167 stick.classList.remove('_nbl_play'); 2168 svg.classList.remove('_nbl_play'); 2169 busy = false; 2170 }, 2600); 2171 }); 2172 })(); 2173 2174 // Auto-collapse burger menu on outside click 2175 document.addEventListener('click', function (event) {
2176 const navbarCollapse = document.getElementById('navbarSupportedContent'); 2177 const navbarToggler = document.querySelector('.navbar-toggler'); 2178 if (navbarCollapse && navbarCollapse.classList.contains('show')) { 2179 if (!navbarCollapse.contains(event.target) && !navbarToggler.contains(event.target)) { 2180 new bootstrap.Collapse(navbarCollapse).hide(); 2181 } 2182 } 2183 }); 2184 2185 // Service Worker Registration for PWA 2186 if ('serviceWorker' in navigator) { 2187 window.addEventListener('load', () => { 2188 navigator.serviceWorker.register('/static/sw.js') 2189 .then(reg => console.log('[PWA] Service Worker registered:', reg.scope)) 2190 .catch(err => console.log('[PWA] Service Worker registration failed:', err)); 2191 }); 2192 } 2193 }); 2194 2195 </script>
2195 2196
2197<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
2197 2198 2199
2200<script> 2201 2202 /* ââ Three.js Star Warp for Login Background ââ */ 2203 let loginSceneData = null; 2204 2205 function initLoginScene() { 2206 const canvas = document.getElementById('login-canvas'); 2207 if (!canvas) return; 2208 2209 // three.js is loaded from a CDN that can fail on some networks â degrade gracefully. 2210 if (typeof THREE === 'undefined') { canvas.style.display = 'none'; return; } 2211 2212 if (document.documentElement.classList.contains('no-gpu') || 2213 window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 2214 canvas.style.display = 'none'; 2215 return; 2216 } 2217 2218 const scene = new THREE.Scene(); 2219 const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); 2220 const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); 2221 2222 renderer.setSize(window.innerWidth, window.innerHeight); 2223 renderer.setPixelRatio(window.innerWidth <= 768 2224 ? Math.min(window.devicePixelRatio, 1.5) 2225 : Math.min(window.devicePixelRatio, 2)); 2226 2227 const starCount = 1200; 2228 const positions = new Float32Array(starCount * 3); 2229 const velocities = new Float32Array(starCount); 2230 for (let i = 0; i < starCount; i++) { 2231 positions[i * 3] = (Math.random() - 0.5) * 20; 2232 positions[i * 3 + 1] = (Math.random() - 0.5) * 20; 2233 positions[i * 3 + 2] = (Math.random() - 1.0) * 15; 2234 velocities[i] = 0.006 + Math.random() * 0.01; 2235 } 2236 const geometry = new THREE.BufferGeometry(); 2237 geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); 2238 2239 const isDark = document.body.classList.contains('dark-theme'); 2240 const material = new THREE.PointsMaterial({ 2241 color: isDark ? 0x2293f2 : 0x1e293b, 2242 size: 0.04, 2243 transparent: true, 2244 opacity: isDark ? 0.4 : 0.7, 2245 blending: isDark ? THREE.AdditiveBlending : THREE.NormalBlending 2246 }); 2247 2248 const starField = new THREE.Points(geometry, material); 2249 scene.add(starField); 2250 camera.position.z = 5; 2251 2252 let mouseX = 0, mouseY = 0; 2253 const onMouseMove = (e) => { 2254 mouseX = (e.clientX / window.innerWidth - 0.5) * 0.3; 2255 mouseY = (e.clientY / window.innerHeight - 0.5) * 0.3; 2256 }; 2257 window.addEventListener('mousemove', onMouseMove); 2258 2259 const onResize = () => { 2260 camera.aspect = window.innerWidth / window.innerHeight; 2261 camera.updateProjectionMatrix(); 2262 renderer.setSize(window.innerWidth, window.innerHeight); 2263 }; 2264 window.addEventListener('resize', onResize); 2265 2266 loginSceneData = { scene, camera, renderer, material, geometry, velocities, starField }; 2267 2268 function animate() { 2269 requestAnimationFrame(animate); 2270 const pos = geometry.attributes.position.array; 2271 for (let i = 0; i < starCount; i++) { 2272 pos[i * 3 + 2] += velocities[i]; 2273 if (pos[i * 3 + 2] > 5) pos[i * 3 + 2] = -15; 2274 } 2275 geometry.attributes.position.needsUpdate = true; 2276 starField.rotation.z += 0.0002; 2277 camera.position.x += (mouseX - camera.position.x) * 0.05; 2278 camera.position.y += (-mouseY - camera.position.y) * 0.05; 2279 camera.lookAt(0, 0, 0); 2280 renderer.render(scene, camera); 2281 } 2282 animate(); 2283 } 2284 2285 window.setThemeCallback = (dark) => { 2286 if (loginSceneData) { 2287 loginSceneData.material.color.setHex(dark ? 0x2293f2 : 0x1e293b); 2288 loginSceneData.material.opacity = dark ? 0.4 : 0.7; 2289 loginSceneData.material.blending = dark ? THREE.AdditiveBlending : THREE.NormalBlending; 2290 } 2291 }; 2292 2293 document.addEventListener('DOMContentLoaded', () => { 2294 initLoginScene(); 2295 }); 2296</script>
2296 2297 2298
2298<script> 2299 (function(){ 2300 var s=document.createElement('script'); 2301 s.async=true; 2302 s.src='https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js'; 2303 s.onload=function(){ 2304 if(!window.webVitals)return; 2305 function sv(m){ 2306 try{navigator.sendBeacon('/api/vitals',JSON.stringify({n:m.name,v:m.value,p:location.pathname}));}catch(e){} 2307 } 2308 webVitals.onFCP(sv);webVitals.onLCP(sv);webVitals.onCLS(sv);webVitals.onINP(sv);webVitals.onTTFB(sv); 2309 }; 2310 document.head.appendChild(s); 2311 })(); 2312 </script>
2312 2313 2314 <!-- ========= TERMINAL COPY BUTTONS ========= 2315 Adds a "Copy" button to every .terminal-container (command/code blocks on 2316 get-started, guides, yt-dlp builderâ¦). Replaces the undiscoverable 2317 click-anywhere-to-copy with an obvious affordance + copied-state feedback. 2318 Terminals are always dark, so this light-on-glass button reads in both themes. --> 2319 <style> 2320 .terminal-container { position: relative; } 2321 .ss-term-copy { 2322 display: inline-flex; 2323 align-items: center; 2324 gap: 6px; 2325 padding: 4px 9px; 2326 font-size: 0.7rem; 2327 font-weight: 600; 2328 line-height: 1; 2329 font-family: inherit; 2330 color: #cbd5e1; 2331 background: rgba(255, 255, 255, 0.08); 2332 border: 1px solid rgba(255, 255, 255, 0.14); 2333 border-radius: var(--radius-sm); 2334 cursor: pointer; 2335 transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.1s ease; 2336 } 2337 .ss-term-copy:hover { background: rgba(255, 255, 255, 0.16); color: #fff; } 2338 .ss-term-copy:active { transform: scale(0.94); } 2339 .ss-term-copy.copied { 2340 color: #4ade80; 2341 border-color: rgba(74, 222, 128, 0.4); 2342 background: rgba(74, 222, 128, 0.14); 2343 } 2344 /* Floating variant for terminals with no title bar â reserve room so the 2345 button never overlaps the first line of the command. */ 2346 .ss-term-copy--float { position: absolute; top: 8px; right: 8px; z-index: 3; } 2347 .terminal-container.has-floating-copy .terminal-body { padding-top: 2.5rem; } 2348 </style> 2349
2349<script> 2350 (function () { 2351 function addCopyButtons() { 2352 document.querySelectorAll('.terminal-container').forEach(function (term) { 2353 if (term.querySelector('.ss-term-copy')) return; // already enhanced 2354 var body = term.querySelector('.terminal-body'); 2355 if (!body) return; 2356 var header = term.querySelector('.terminal-header'); 2357 2358 var btn = document.createElement('button'); 2359 btn.type = 'button'; 2360 btn.className = 'ss-term-copy'; 2361 btn.setAttribute('aria-label', 'Copy to clipboard'); 2362 btn.innerHTML = '<i class="fa-solid fa-copy" aria-hidden="true"></i><span>Copy</span>'; 2363 2364 if (header) { 2365 // sit at the right of the title bar; only self-align when the header 2366 // has no existing right-aligned element (e.g. a "Nightbot" label). 2367 if (!header.querySelector('.ms-auto')) btn.style.marginLeft = 'auto'; 2368 header.appendChild(btn); 2369 } else { 2370 btn.classList.add('ss-term-copy--float'); 2371 term.classList.add('has-floating-copy'); 2372 term.appendChild(btn); 2373 } 2374 2375 btn.addEventListener('click', function (e) { 2376 e.stopPropagation(); 2377 var text = (body.innerText || '').trim(); 2378 navigator.clipboard.writeText(text).then(function () { 2379 btn.classList.add('copied'); 2380 btn.innerHTML = '<i class="fa-solid fa-check" aria-hidden="true"></i><span>Copied</span>'; 2381 setTimeout(function () { 2382 btn.classList.remove('copied'); 2383 btn.innerHTML = '<i class="fa-solid fa-copy" aria-hidden="true"></i><span>Copy</span>'; 2384 }, 1800); 2385 }).catch(function () {}); 2386 }); 2387 }); 2388 } 2389 document.addEventListener('DOMContentLoaded', addCopyButtons); 2390 if (document.readyState !== 'loading') addCopyButtons(); 2391 })(); 2392 </script>
2392 2393 2394 <!-- ========= PALETTE DROPDOWN (wiring) ========= 2395 Lets a signed-in user pick an accent palette from the navbar. The choice is 2396 stored in localStorage (client-side only â does NOT change the CSS file or 2397 affect other users) and re-applied pre-paint by the head script. --> 2398 <style> 2399 .palette-dot { 2400 display: inline-block; 2401 width: 12px; 2402 height: 12px; 2403 border-radius: 50%; 2404 margin-inline-end: 8px; 2405 vertical-align: middle; 2406 box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25); 2407 } 2408 </style> 2409
2409<script> 2410 (function () { 2411 function apply(h, s, l, rgb) { 2412 var st = document.documentElement.style; 2413 if (h === null) { 2414 st.removeProperty('--accent-h'); st.removeProperty('--accent-s'); 2415 st.removeProperty('--accent-l'); st.removeProperty('--color-primary-rgb'); 2416 localStorage.removeItem('ss_palette'); 2417 return; 2418 } 2419 st.setProperty('--accent-h', h); st.setProperty('--accent-s', s); 2420 st.setProperty('--accent-l', l); st.setProperty('--color-primary-rgb', rgb); 2421 localStorage.setItem('ss_palette', JSON.stringify({ h: h, s: s, l: l, rgb: rgb })); 2422 } 2423 function wire() { 2424 document.querySelectorAll('.palette-opt').forEach(function (opt) { 2425 if (opt._wired) return; opt._wired = true; 2426 opt.addEventListener('click', function (e) { 2427 e.preventDefault(); 2428 if (opt.dataset.reset) apply(null); 2429 else apply(opt.dataset.h, opt.dataset.s, opt.dataset.l, opt.dataset.rgb); 2430 }); 2431 }); 2432 } 2433 document.addEventListener('DOMContentLoaded', wire); 2434 if (document.readyState !== 'loading') wire(); 2435 })(); 2436 </script>
2436 2437</body> 2438 2439</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.