Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 14455 lines 528 kB view raw
1<!DOCTYPE html> 2<html> 3<head> 4 <title>Give · Aesthetic Computer</title> 5 <!-- SVG filter for image sharpening (unsharp mask) --> 6 <svg width="0" height="0" style="position:absolute"> 7 <filter id="sharpen"> 8 <feConvolveMatrix order="3" preserveAlpha="true" 9 kernelMatrix="0 -0.5 0 -0.5 3 -0.5 0 -0.5 0" /> 10 </filter> 11 </svg> 12 <meta name="description" content="Aesthetic Computer" /> 13 <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 14 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 15 16 <!-- Social sharing meta tags --> 17 <meta property="og:title" content="Give · Aesthetic Computer" /> 18 <meta property="og:description" content="Aesthetic Computer" /> 19 <meta property="og:image" content="https://assets.aesthetic.computer/give-og.jpg" /> 20 <meta property="og:image:width" content="1200" /> 21 <meta property="og:image:height" content="630" /> 22 <meta property="og:url" content="https://give.aesthetic.computer" /> 23 <meta property="og:type" content="website" /> 24 <meta name="twitter:card" content="summary_large_image" /> 25 <meta name="twitter:title" content="Give · Aesthetic Computer" /> 26 <meta name="twitter:description" content="Aesthetic Computer" /> 27 <meta name="twitter:image" content="https://assets.aesthetic.computer/give-og.jpg" /> 28 <meta name="twitter:site" content="@aestheticco_mp" /> 29 30 <!-- Fonts --> 31 <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/ywft-processing-regular.css"> 32 <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css"> 33 <!-- Flag icons for language selector --> 34 <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" /> 35 <link rel="preconnect" href="https://fonts.googleapis.com"> 36 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 37 <link href="https://fonts.googleapis.com/css2?family=Comic+Relief:wght@400;700&display=swap" rel="stylesheet"> 38 39 <script src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js"></script> 40 <script src="https://cdn.auth0.com/js/auth0-spa-js/2.0/auth0-spa-js.production.js"></script> 41 <!-- 42 TODO: Give Page Improvements (2026.01.03) 43 ========================================= 44 [x] 1. Shop module: Show multiple images per product before switching to next product 45 - Cycle through all product images with a faster interval (2.5s) 46 - Only switch to next product after showing all images 47 - Added image counter dots indicator 48 49 [x] 2. Shop module: Product descriptions - support newlines and proper spacing 50 - Added white-space: pre-line for newline support 51 - Increased line-height to 1.6 and margin 52 - Increased truncation limit to 150 chars 53 54 [x] 3. SOLD badges: Change from pink to red/white in red box 55 - Background: #d32f2f (red) 56 - Text: white 57 - Style: bold badge with padding and border-radius 58 59 [x] 4. KidLisp panel links: Change from aesthetic.computer/$code to kidlisp.com/$code 60 - Updated pieceUrl generation 61 - Updated QR code URL generation 62 63 All tasks completed! 🎉 64 --> 65 <style> 66 /* Dark mode (default) */ 67 :root { 68 --bg: #1a1a2e; 69 --text: #e8e8e8; 70 --dim: #888; 71 --pink: #ff6b9d; 72 --cyan: #4ecdc4; 73 --gold: #ffd93d; 74 --green: #6bcb77; 75 --box-bg: rgba(255,255,255,0.03); 76 --box-border: rgba(255,255,255,0.1); 77 --mono: 'Berkeley Mono Variable', 'Menlo', monospace; 78 /* Light mode specific overrides */ 79 --shadow-soft: 0 2px 8px rgba(0,0,0,0.15); 80 --shadow-medium: 0 4px 16px rgba(0,0,0,0.2); 81 --input-bg: rgba(255,255,255,0.05); 82 --slider-track: linear-gradient(90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3, #54a0ff, #5f27cd, #00d2d3); 83 } 84 85 /* Light mode theme */ 86 body.light-mode { 87 --bg: #f5f5f5; 88 --text: #1a1a2e; 89 --dim: #666; 90 --pink: rgb(205, 92, 155); 91 --cyan: #0891b2; 92 --gold: #d97706; 93 --green: #059669; 94 --box-bg: rgba(0,0,0,0.03); 95 --box-border: rgba(0,0,0,0.12); 96 --shadow-soft: 0 2px 8px rgba(0,0,0,0.08); 97 --shadow-medium: 0 4px 16px rgba(0,0,0,0.12); 98 --input-bg: white; 99 --slider-track: linear-gradient(90deg, #f87171, #fbbf24, #22d3ee, #f472b6, #60a5fa, #8b5cf6, #06b6d4); 100 } 101 102 * { box-sizing: border-box; } 103 104 html { 105 overflow-x: hidden; 106 scrollbar-gutter: stable; /* Reserve space for scrollbar on right only */ 107 } 108 109 body { 110 font-family: var(--mono); 111 background: var(--bg); 112 color: var(--text); 113 margin: 0; 114 padding: 2em; 115 min-height: 100vh; 116 font-size: 14px; 117 line-height: 1.6; 118 overflow-x: hidden; 119 -webkit-text-size-adjust: 100%; /* Prevent Safari from inflating fonts */ 120 text-size-adjust: 100%; 121 user-select: none; 122 -webkit-user-select: none; 123 } 124 125 /* Allow text selection on captions, links, and copyable content */ 126 .module-caption, 127 .link-block a, 128 a[href], 129 .shop-product-desc, 130 .kidlisp-desc, 131 .prose, 132 .invest-content, 133 input, 134 textarea { 135 user-select: text; 136 -webkit-user-select: text; 137 } 138 139 /* Top bar with logo and language chooser */ 140 .top-bar { 141 position: fixed; 142 top: 0; 143 left: 0; 144 width: 100%; 145 max-width: 100vw; 146 display: flex; 147 justify-content: space-between; 148 align-items: center; 149 padding: 10px 16px; 150 z-index: 1000; 151 background: linear-gradient(to bottom, var(--bg) 0%, var(--bg) 60%, transparent 100%); 152 padding-bottom: 20px; 153 } 154 155 /* Simple gift label */ 156 .logo { 157 font-size: 18px; 158 cursor: pointer; 159 user-select: none; 160 } 161 162 .logo:hover { 163 opacity: 0.8; 164 } 165 166 .logo-give { 167 font-family: var(--mono); 168 font-weight: 500; 169 color: var(--text); 170 } 171 172 .logo-ac { 173 font-family: 'YWFTProcessing-Regular', sans-serif; 174 font-size: 1.15em; 175 color: var(--pink); 176 } 177 178 .logo-dot { 179 color: var(--cyan); 180 } 181 182 .logo-sep { 183 color: var(--dim); 184 margin: 0 0.3em; 185 font-family: var(--mono); 186 } 187 188 .logo-year { 189 font-family: 'YWFTProcessing-Regular', sans-serif; 190 color: var(--pink); 191 font-size: 1.15em; 192 margin-left: 0.3em; 193 } 194 195 main { 196 max-width: 1200px; 197 margin: 0 auto; 198 padding-top: 2.5em; 199 overflow: visible; 200 } 201 202 /* Grid layout for wider screens */ 203 .content-grid { 204 display: grid; 205 grid-template-columns: 1fr; 206 max-width: 100%; 207 gap: 1em; 208 } 209 210 @media (min-width: 700px) { 211 .content-grid { 212 grid-template-columns: repeat(2, 1fr); 213 } 214 .content-grid .full-width { 215 grid-column: 1 / -1; 216 } 217 } 218 219 @media (min-width: 1000px) { 220 .content-grid { 221 grid-template-columns: repeat(3, 1fr); 222 } 223 .content-grid .span-2 { 224 grid-column: span 2; 225 } 226 } 227 228 h1 { 229 font-family: 'YWFTProcessing-Regular', sans-serif; 230 font-weight: normal; 231 font-size: 1.8em; 232 margin: 0 0 0.5em 0; 233 color: var(--text); 234 grid-column: 1 / -1; 235 } 236 237 h1::before { 238 content: "❄ "; 239 opacity: 0.6; 240 } 241 242 h1::after { 243 content: " ✦"; 244 opacity: 0.6; 245 } 246 247 h1 .bounce-dot { 248 display: inline-block; 249 font-weight: 900; 250 font-size: 1.1em; 251 color: var(--pink); 252 animation: dotBounce 1.2s ease-in-out 3; 253 } 254 255 @keyframes dotBounce { 256 0%, 100% { transform: translateY(0) scale(1); } 257 50% { transform: translateY(-6px) scale(1.2); } 258 } 259 260 .holiday-banner { 261 font-family: var(--mono); 262 font-size: 0.75em; 263 color: var(--dim); 264 padding: 0.6em; 265 background: var(--box-bg); 266 border: 1px solid var(--box-border); 267 border-radius: 3px; 268 text-align: center; 269 grid-column: 1 / -1; 270 } 271 272 .holiday-banner span { color: var(--gold); } 273 274 /* Prose blocks - language-switchable */ 275 .prose { 276 background: var(--box-bg); 277 border: 1px solid var(--box-border); 278 border-radius: 4px; 279 padding: 1em 1.2em; 280 font-size: 0.9em; 281 line-height: 1.7; 282 } 283 284 .prose.full-width { 285 grid-column: 1 / -1; 286 } 287 288 /* Why Give + Bills transparency bridge */ 289 .why-give-section { 290 grid-column: 1 / -1; 291 background: var(--box-bg); 292 border: 1px solid var(--box-border); 293 border-left: 3px solid var(--pink); 294 border-radius: 6px; 295 padding: 0.9em 1.1em; 296 font-size: 0.9em; 297 line-height: 1.7; 298 } 299 300 .why-give-title { 301 font-family: 'YWFTProcessing-Regular', sans-serif; 302 font-size: 1.1em; 303 color: var(--pink); 304 margin-bottom: 0.3em; 305 letter-spacing: 0.02em; 306 } 307 308 .why-give-section .why-link { 309 color: var(--gold); 310 font-weight: 700; 311 text-decoration: none; 312 border-bottom: 1px dotted currentColor; 313 } 314 315 .why-give-section .why-link:hover { 316 text-decoration: underline; 317 } 318 319 /* Language content visibility - All non-English hidden by default */ 320 /* Exclude dropdown options from language hiding */ 321 [data-lang="da"]:not(.lang-option):not(.curr-option), 322 [data-lang="zh"]:not(.lang-option):not(.curr-option), 323 [data-lang="de"]:not(.lang-option):not(.curr-option), 324 [data-lang="es"]:not(.lang-option):not(.curr-option) { display: none !important; } 325 [data-lang="en"]:not(.lang-option):not(.curr-option) { display: block; } 326 327 /* When Danish is selected */ 328 body.lang-da [data-lang="da"]:not(.lang-option):not(.curr-option) { display: block !important; } 329 body.lang-da [data-lang="en"]:not(.lang-option):not(.curr-option), 330 body.lang-da [data-lang="zh"]:not(.lang-option):not(.curr-option), 331 body.lang-da [data-lang="de"]:not(.lang-option):not(.curr-option), 332 body.lang-da [data-lang="es"]:not(.lang-option):not(.curr-option) { display: none !important; } 333 334 /* When Chinese is selected */ 335 body.lang-zh [data-lang="zh"]:not(.lang-option):not(.curr-option) { display: block !important; } 336 body.lang-zh [data-lang="en"]:not(.lang-option):not(.curr-option), 337 body.lang-zh [data-lang="da"]:not(.lang-option):not(.curr-option), 338 body.lang-zh [data-lang="de"]:not(.lang-option):not(.curr-option), 339 body.lang-zh [data-lang="es"]:not(.lang-option):not(.curr-option) { display: none !important; } 340 341 /* When German is selected */ 342 body.lang-de [data-lang="de"]:not(.lang-option):not(.curr-option) { display: block !important; } 343 body.lang-de [data-lang="en"]:not(.lang-option):not(.curr-option), 344 body.lang-de [data-lang="da"]:not(.lang-option):not(.curr-option), 345 body.lang-de [data-lang="zh"]:not(.lang-option):not(.curr-option), 346 body.lang-de [data-lang="es"]:not(.lang-option):not(.curr-option) { display: none !important; } 347 348 /* When Spanish is selected */ 349 body.lang-es [data-lang="es"]:not(.lang-option):not(.curr-option) { display: block !important; } 350 body.lang-es [data-lang="en"]:not(.lang-option):not(.curr-option), 351 body.lang-es [data-lang="da"]:not(.lang-option):not(.curr-option), 352 body.lang-es [data-lang="zh"]:not(.lang-option):not(.curr-option), 353 body.lang-es [data-lang="de"]:not(.lang-option):not(.curr-option) { display: none !important; } 354 355 /* Link description spans */ 356 .link-desc { display: inline; } 357 .link-desc[data-lang="da"], .link-desc[data-lang="zh"], .link-desc[data-lang="de"], .link-desc[data-lang="es"] { display: none !important; } 358 body.lang-da .link-desc[data-lang="da"] { display: inline !important; } 359 body.lang-da .link-desc[data-lang="en"], body.lang-da .link-desc[data-lang="zh"], body.lang-da .link-desc[data-lang="de"], body.lang-da .link-desc[data-lang="es"] { display: none !important; } 360 body.lang-zh .link-desc[data-lang="zh"] { display: inline !important; } 361 body.lang-zh .link-desc[data-lang="en"], body.lang-zh .link-desc[data-lang="da"], body.lang-zh .link-desc[data-lang="de"], body.lang-zh .link-desc[data-lang="es"] { display: none !important; } 362 body.lang-de .link-desc[data-lang="de"] { display: inline !important; } 363 body.lang-de .link-desc[data-lang="en"], body.lang-de .link-desc[data-lang="da"], body.lang-de .link-desc[data-lang="zh"], body.lang-de .link-desc[data-lang="es"] { display: none !important; } 364 body.lang-es .link-desc[data-lang="es"] { display: inline !important; } 365 body.lang-es .link-desc[data-lang="en"], body.lang-es .link-desc[data-lang="da"], body.lang-es .link-desc[data-lang="zh"], body.lang-es .link-desc[data-lang="de"] { display: none !important; } 366 367 /* Feature module list items */ 368 .feature-module li[data-lang="da"], .feature-module li[data-lang="zh"], .feature-module li[data-lang="de"], .feature-module li[data-lang="es"] { display: none !important; } 369 body.lang-da .feature-module li[data-lang="da"] { display: block !important; } 370 body.lang-da .feature-module li[data-lang="en"] { display: none !important; } 371 body.lang-zh .feature-module li[data-lang="zh"] { display: block !important; } 372 body.lang-zh .feature-module li[data-lang="en"] { display: none !important; } 373 body.lang-de .feature-module li[data-lang="de"] { display: block !important; } 374 body.lang-de .feature-module li[data-lang="en"] { display: none !important; } 375 body.lang-es .feature-module li[data-lang="es"] { display: block !important; } 376 body.lang-es .feature-module li[data-lang="en"] { display: none !important; } 377 378 a { color: var(--cyan); text-decoration: none; } 379 a:hover { text-decoration: underline; } 380 381 a.handle-link { color: var(--pink); } 382 383 code { 384 background: var(--box-bg); 385 padding: 0.15em 0.4em; 386 border-radius: 2px; 387 color: var(--pink); 388 } 389 390 code a { color: var(--pink); } 391 392 /* Stats Section */ 393 .stats-section { 394 display: grid; 395 grid-template-columns: repeat(3, 1fr); 396 gap: 0.6em; 397 grid-column: 1 / -1; 398 } 399 400 @media (min-width: 800px) { 401 .stats-section { 402 grid-template-columns: repeat(6, 1fr); 403 } 404 } 405 406 .stat-item { 407 text-align: center; 408 padding: 0.6em 0.4em; 409 background: var(--stat-bg, var(--box-bg)); 410 border: 1px solid var(--box-border); 411 border-radius: 4px; 412 display: flex; 413 flex-direction: column; 414 justify-content: center; 415 align-items: center; 416 overflow: hidden; 417 position: relative; 418 user-select: none; 419 min-height: 60px; 420 } 421 422 #stat-handles { --stat-bg: rgba(100, 200, 255, 0.08); } 423 #stat-paintings { --stat-bg: rgba(255, 180, 100, 0.08); } 424 #stat-moods { --stat-bg: rgba(255, 120, 180, 0.08); } 425 #stat-kidlisp { --stat-bg: rgba(180, 100, 255, 0.08); } 426 #stat-commands { --stat-bg: rgba(100, 255, 150, 0.08); } 427 #stat-messages { --stat-bg: rgba(255, 220, 100, 0.08); } 428 429 .stat-value { 430 font-family: 'YWFTProcessing-Regular', var(--mono); 431 font-size: 1.8em; 432 color: var(--gold); 433 position: relative; 434 z-index: 2; 435 text-shadow: 0 2px 6px rgba(0, 0, 0, 0.8), 0 1px 2px rgba(0, 0, 0, 0.9); 436 } 437 438 .stat-label { 439 font-size: 0.9em; 440 font-weight: 600; 441 color: var(--dim); 442 margin-top: 0.2em; 443 position: relative; 444 z-index: 2; 445 text-shadow: 0 1px 1px rgba(0, 0, 0, 0.7); 446 } 447 448 .stat-sigil { 449 color: var(--cyan); 450 margin-right: 0.1em; 451 } 452 453 /* Floating items in stat boxes */ 454 .stat-floaters { 455 position: absolute; 456 inset: 0; 457 overflow: hidden; 458 z-index: 1; 459 pointer-events: none; 460 } 461 462 .stat-floater { 463 position: absolute; 464 display: flex; 465 flex-direction: column; 466 align-items: center; 467 gap: 2px; 468 animation: floatUp var(--float-duration, 12s) linear forwards; 469 opacity: 0.35; 470 } 471 472 .stat-floater.float-horizontal { 473 animation: floatLeft var(--float-duration, 12s) linear forwards; 474 } 475 476 .stat-floater.float-horizontal-right { 477 animation: floatRight var(--float-duration, 12s) linear forwards; 478 } 479 480 .stat-floater img { 481 object-fit: contain; 482 border-radius: 2px; 483 image-rendering: pixelated; 484 } 485 486 .stat-floater .floater-code { 487 font-family: var(--mono); 488 font-size: 0.5em; 489 color: var(--dim); 490 white-space: nowrap; 491 } 492 493 @keyframes floatUp { 494 0% { 495 bottom: -20px; 496 } 497 100% { 498 bottom: 120%; 499 } 500 } 501 502 @keyframes floatLeft { 503 0% { 504 right: -50px; 505 } 506 100% { 507 right: 120%; 508 } 509 } 510 511 @keyframes floatRight { 512 0% { 513 left: -80px; 514 } 515 100% { 516 left: 120%; 517 } 518 } 519 520 /* Gives Feed */ 521 .recent-gives { 522 grid-column: 1 / -1; 523 background: var(--box-bg); 524 border: 1px solid var(--box-border); 525 border-radius: 8px; 526 padding: 1em; 527 overflow: hidden; 528 } 529 530 /* Gives Ticker - horizontal scrolling marquee */ 531 .gives-ticker { 532 background: linear-gradient(180deg, rgba(0, 40, 20, 0.4) 0%, rgba(0, 20, 10, 0.5) 100%); 533 border: 1px solid rgba(100, 255, 150, 0.2); 534 border-bottom: none; 535 border-radius: 8px 8px 0 0; 536 margin-bottom: 0; 537 position: relative; 538 height: 2.4em; 539 overflow: hidden; 540 cursor: grab; 541 user-select: none; 542 } 543 544 .gives-ticker.dragging { 545 cursor: grabbing; 546 } 547 548 .gives-ticker.dragging .gives-ticker-track { 549 animation: none !important; 550 } 551 552 .gives-ticker-track { 553 position: absolute; 554 top: 0; 555 left: 0; 556 display: flex; 557 align-items: center; 558 height: 100%; 559 white-space: nowrap; 560 animation: ticker-scroll 30s linear infinite; 561 } 562 563 /* Pause animation when not visible */ 564 .gives-ticker.paused .gives-ticker-track { 565 animation-play-state: paused; 566 } 567 568 @keyframes ticker-scroll { 569 0% { transform: translateX(0); } 570 100% { transform: translateX(-33.333%); } 571 } 572 573 @keyframes tickerScroll { 574 0% { transform: translateX(0%); } 575 100% { transform: translateX(-50%); } 576 } 577 578 .gives-ticker-item { 579 display: inline-flex; 580 align-items: center; 581 padding: 0 1.5em; 582 font-size: 0.85em; 583 border-right: 1px solid rgba(100, 255, 150, 0.15); 584 gap: 0.35em; 585 } 586 587 .gives-ticker-item .got { 588 color: var(--cyan); 589 font-weight: 500; 590 } 591 592 .gives-ticker-item .amount { 593 color: var(--green); 594 font-weight: bold; 595 } 596 597 .gives-ticker-item .time { 598 color: var(--dim); 599 font-style: italic; 600 } 601 602 .gives-ticker-item .note { 603 color: var(--gold); 604 } 605 606 .gives-ticker-item .sep { 607 color: var(--dim); 608 } 609 610 /* Monthly/recurring gives - yellow theme */ 611 .gives-ticker-item.monthly { 612 border-right-color: rgba(255, 215, 100, 0.2); 613 } 614 615 .gives-ticker-item.monthly .got { 616 color: var(--gold); 617 } 618 619 .gives-ticker-item.monthly .amount { 620 color: var(--gold); 621 } 622 623 .gives-ticker-item.monthly .time { 624 color: var(--gold); 625 opacity: 0.7; 626 } 627 628 .gives-ticker-empty { 629 display: flex; 630 align-items: center; 631 justify-content: center; 632 height: 100%; 633 min-height: 2.4em; 634 color: var(--dim); 635 font-size: 0.9em; 636 } 637 638 /* CTA as ticker item (scrolling) */ 639 .gives-ticker-cta-item { 640 color: var(--cyan); 641 padding: 0 2em !important; 642 white-space: nowrap; 643 } 644 .gives-ticker-cta-item .pink-handle { 645 color: var(--pink); 646 text-decoration: none; 647 padding: 0 0.3em; 648 } 649 .gives-ticker-cta-item .pink-handle:hover { 650 text-decoration: underline; 651 } 652 653 .gives-ticker-empty a { 654 color: var(--gold); 655 text-decoration: none; 656 margin-left: 0.3em; 657 } 658 659 .gives-ticker-empty a:hover { 660 text-decoration: underline; 661 } 662 663 /* Hide old gives styles */ 664 .recent-gives { 665 display: none; 666 } 667 668 .recent-gives-header { 669 display: none; 670 } 671 672 .gives-list { 673 display: none; 674 } 675 676 .give-item { 677 display: none; 678 } 679 680 .gives-loading { 681 text-align: center; 682 color: var(--dim); 683 font-size: 0.85em; 684 padding: 1em; 685 } 686 687 /* Unified module loading spinner */ 688 .module-loading { 689 position: absolute; 690 inset: 0; 691 display: flex; 692 align-items: center; 693 justify-content: center; 694 flex-direction: column; 695 gap: 0.5em; 696 color: rgba(255, 255, 255, 0.4); 697 font-family: var(--mono); 698 font-size: 0.8em; 699 } 700 701 .module-loading::before { 702 content: ""; 703 width: 24px; 704 height: 24px; 705 border: 2px solid rgba(255, 255, 255, 0.1); 706 border-top-color: rgba(255, 255, 255, 0.4); 707 border-radius: 50%; 708 animation: moduleSpinner 0.8s linear infinite; 709 } 710 711 @keyframes moduleSpinner { 712 to { transform: rotate(360deg); } 713 } 714 715 .module-loading.hidden { 716 display: none; 717 } 718 719 /* Module caption - positioned at bottom of content boxes */ 720 .module-caption { 721 position: absolute; 722 bottom: 0; 723 left: 0; 724 right: 0; 725 padding: 0.8em 1em; 726 font-size: 0.75em; 727 color: var(--dim); 728 background: inherit; 729 z-index: 2; 730 } 731 732 .module-caption .highlight { 733 color: var(--yellow); 734 font-family: var(--mono); 735 } 736 737 .cmd-highlight { 738 color: var(--pink); 739 font-family: var(--mono); 740 } 741 742 /* Fiat Currency Section */ 743 .fiat-section { 744 background: transparent; 745 border: none; 746 border-radius: 0; 747 padding: 0; 748 } 749 750 .fiat-section.full-width { 751 grid-column: 1 / -1; 752 } 753 754 .fiat-header { 755 display: none; 756 } 757 758 .fiat-header h2 { 759 font-family: 'YWFTProcessing-Regular', sans-serif; 760 font-size: 1em; 761 margin: 0; 762 color: var(--cyan); 763 } 764 765 /* Language and Currency selectors */ 766 .selector-group { 767 display: flex; 768 align-items: center; 769 gap: 12px; 770 } 771 772 /* Language dropdown (compact, like kidlisp.com) */ 773 .lang-selector { 774 position: relative; 775 display: flex; 776 align-items: center; 777 gap: 4px; 778 padding: 6px 10px; 779 cursor: pointer; 780 font-size: 12px; 781 user-select: none; 782 border-radius: 4px; 783 transition: background 0.15s; 784 } 785 786 .lang-selector:hover { 787 background: var(--box-bg); 788 } 789 790 .lang-selector .lang-flag { font-size: 14px; } 791 .lang-selector .lang-text { 792 font-size: 12px; 793 color: var(--dim); 794 font-weight: 500; 795 } 796 .lang-selector .lang-arrow { 797 font-size: 8px; 798 color: var(--dim); 799 opacity: 0.6; 800 margin-left: 2px; 801 } 802 803 .lang-dropdown { 804 position: absolute; 805 top: 100%; 806 left: 0; 807 background: var(--bg); 808 border: 1px solid var(--box-border); 809 border-radius: 4px; 810 box-shadow: 0 4px 12px rgba(0,0,0,0.15); 811 z-index: 100; 812 min-width: 100px; 813 margin-top: 2px; 814 display: none; 815 } 816 817 .lang-selector.open .lang-dropdown { 818 display: block; 819 } 820 821 .lang-option { 822 padding: 8px 12px; 823 cursor: pointer; 824 font-size: 12px; 825 color: var(--text); 826 transition: all 0.15s; 827 } 828 829 .lang-option:hover { 830 background: rgba(232, 74, 138, 0.1); 831 color: var(--pink); 832 } 833 834 .lang-option:first-child { border-radius: 4px 4px 0 0; } 835 .lang-option:last-child { border-radius: 0 0 4px 4px; } 836 837 /* Currency link selector */ 838 .currency-links { 839 display: flex; 840 align-items: center; 841 gap: 16px; 842 margin-right: calc(max(0px, (100vw - 1200px) / 2)); 843 } 844 845 .curr-link { 846 font-size: 13px; 847 color: var(--dim); 848 cursor: pointer; 849 transition: color 0.15s; 850 text-decoration: none; 851 font-weight: 500; 852 } 853 854 .curr-link:hover { 855 color: var(--text); 856 } 857 858 .curr-link.active { 859 color: var(--pink); 860 } 861 862 .curr-link .curr-flag { margin-right: 4px; } 863 864 /* Different active colors per currency */ 865 .curr-link.active[data-curr="usd"] { color: var(--green); } 866 .curr-link.active[data-curr="dkk"] { color: var(--cyan); } 867 .curr-link.active[data-curr="crypto"] { color: #a78bfa; } 868 .curr-link.active[data-curr="paypal"] { color: #0070ba; } 869 .curr-link.active[data-curr="liberapay"] { color: #f6c915; } 870 871 @media (max-width: 600px) { 872 body { 873 padding: 1em; 874 padding-right: calc(1em + 10px); 875 font-size: 13px; 876 } 877 .top-bar { 878 padding: 6px 10px; 879 background: linear-gradient(to bottom, rgba(5, 15, 20, 0.95) 0%, rgba(5, 15, 20, 0.85) 50%, transparent 100%); 880 flex-wrap: nowrap; 881 padding-bottom: 16px; 882 } 883 .logo { 884 font-size: 13px; 885 white-space: nowrap; 886 } 887 /* Hide "aesthetic.computer" on very small screens, just show "give · AC '26" */ 888 .logo-ac { 889 display: none; 890 } 891 .logo-year { 892 display: none; 893 } 894 .logo::after { 895 content: "AC '26"; 896 font-family: 'YWFTProcessing-Regular', sans-serif; 897 color: var(--pink); 898 font-size: 1.15em; 899 } 900 main { 901 padding-top: 4em; 902 } 903 .gives-ticker { 904 height: 2.1em; 905 } 906 .gives-ticker-item { 907 font-size: 0.75em; 908 padding: 0 1em; 909 } 910 .currency-links { 911 gap: 10px; 912 } 913 .curr-link { 914 font-size: 12px; 915 } 916 /* Show labels on mobile too */ 917 .lang-selector .lang-text { display: none; } 918 } 919 920 /* Hide old dropdown styles */ 921 .dropdown-selector { display: none !important; } 922 border: 1px solid var(--box-border); 923 border-radius: 4px; 924 display: none; 925 min-width: 100px; 926 box-shadow: 0 4px 12px rgba(0,0,0,0.15); 927 z-index: 100; 928 } 929 930 .dropdown-selector.open .dropdown-menu { 931 display: block; 932 } 933 934 .dropdown-option { 935 display: flex; 936 align-items: center; 937 gap: 8px; 938 padding: 8px 12px; 939 cursor: pointer; 940 font-size: 12px; 941 color: var(--text); 942 } 943 944 .dropdown-option:hover { 945 background: var(--box-bg); 946 } 947 948 .dropdown-option.active { 949 color: var(--pink); 950 font-weight: bold; 951 } 952 953 .currency-pickers { 954 width: 100%; 955 max-width: 100%; 956 overflow: hidden; 957 } 958 959 .currency-picker { 960 padding: 0; 961 max-width: 100%; 962 overflow: hidden; 963 } 964 965 .currency-picker h3 { 966 font-family: var(--mono); 967 font-size: 0.85em; 968 margin: 0 0 0.8em 0; 969 color: var(--dim); 970 } 971 972 .gift-widget { 973 display: flex; 974 flex-direction: column; 975 gap: 0; 976 } 977 978 .gift-visual { 979 position: relative; 980 display: flex; 981 justify-content: center; 982 align-items: center; 983 padding: 0; /* No padding - slideshow fills completely */ 984 background: linear-gradient(135deg, rgba(205, 92, 155, 0.1) 0%, rgba(0, 255, 255, 0.05) 100%); 985 border: 1px solid var(--box-border); 986 border-top: none; 987 border-bottom: none; 988 border-radius: 0; 989 /* Safari mobile fix for border-radius */ 990 -webkit-mask-image: -webkit-radial-gradient(white, black); 991 isolation: isolate; 992 overflow: hidden; 993 /* Maintain aspect ratio when page narrows */ 994 aspect-ratio: 21 / 9; 995 min-height: 0; 996 /* Loading state - starts blurred */ 997 filter: blur(8px) brightness(0.7); 998 transition: filter 0.8s ease-out; 999 } 1000 1001 .gift-visual.loaded { 1002 filter: blur(0) brightness(1); 1003 } 1004 1005 /* Ticker wrapper with superscript stats above */ 1006 .ticker-stats-row { 1007 position: relative; 1008 margin-bottom: 0; 1009 } 1010 1011 .ticker-center { 1012 width: 100%; 1013 } 1014 1015 .ticker-subscripts { 1016 display: flex; 1017 justify-content: space-between; 1018 gap: 1em; 1019 margin-bottom: 0.4em; 1020 padding-left: 0.25em; 1021 padding-right: 0.25em; 1022 font-family: var(--mono); 1023 font-size: 0.85em; 1024 } 1025 1026 .ticker-stat-badge { 1027 display: flex; 1028 align-items: center; 1029 gap: 0.3em; 1030 opacity: 0; 1031 transition: opacity 0.4s ease; 1032 } 1033 1034 .ticker-stat-badge.visible { 1035 opacity: 1; 1036 } 1037 1038 .ticker-stat-badge.gives-badge .ticker-stat-value { 1039 color: var(--green); 1040 } 1041 1042 .ticker-stat-badge.monthly-badge .ticker-stat-value { 1043 color: var(--gold); 1044 } 1045 1046 .ticker-stat-value { 1047 font-weight: bold; 1048 } 1049 1050 .ticker-stat-label { 1051 color: var(--dim); 1052 text-transform: lowercase; 1053 } 1054 1055 .ticker-stat-amount { 1056 display: inline; 1057 font-size: 1em; 1058 color: var(--dim); 1059 font-weight: normal; 1060 } 1061 1062 .ticker-stat-amount .got { 1063 color: var(--cyan); 1064 font-weight: 500; 1065 } 1066 1067 .ticker-stat-amount .amount-value { 1068 color: var(--green); 1069 font-weight: bold; 1070 } 1071 1072 .ticker-stat-amount .count-value { 1073 color: var(--cyan); 1074 font-weight: 500; 1075 } 1076 1077 .ticker-stat-amount .so-far { 1078 color: var(--dim); 1079 font-weight: normal; 1080 } 1081 1082 .gift-logo-wrap { 1083 display: none; /* Rendered on canvas instead */ 1084 } 1085 1086 .gift-logo { 1087 /* Fill the wrap container */ 1088 width: 100%; 1089 height: auto; 1090 aspect-ratio: 1; 1091 opacity: 1; 1092 filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.7)) drop-shadow(0 3px 8px rgba(0, 0, 0, 0.5)) brightness(1.2); 1093 transition: opacity 0.4s ease-out, transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease-out; 1094 transform-origin: bottom left; 1095 animation: logo-white-blink 3s ease-in-out infinite; 1096 display: block; 1097 } 1098 1099 @keyframes logo-white-blink { 1100 0%, 85%, 100% { filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.7)) drop-shadow(0 3px 8px rgba(0, 0, 0, 0.5)) brightness(1.2); } 1101 90%, 95% { filter: drop-shadow(0 6px 20px rgba(0, 0, 0, 0.7)) drop-shadow(0 3px 8px rgba(0, 0, 0, 0.5)) brightness(2) drop-shadow(0 0 25px rgba(255,255,255,0.8)); } 1102 } 1103 1104 .gift-widget:hover .gift-logo:not(.intensity-1):not(.intensity-2):not(.intensity-3):not(.intensity-4) { 1105 opacity: 1; 1106 transform: scale(1.05); 1107 } 1108 1109 .gift-amount { 1110 position: absolute; 1111 /* Bottom-left of logo */ 1112 bottom: -5%; 1113 left: -10%; 1114 font-family: 'YWFTProcessing-Regular', var(--mono); 1115 font-size: clamp(1.2em, 2.2vw, 2.5em); 1116 color: #5a9a5a; 1117 background: linear-gradient(135deg, #1a2a1a 0%, #0a1a0a 100%); 1118 border: 2px solid #ff0080; 1119 border-radius: 4px; 1120 padding: 0.25em 0.45em; 1121 box-shadow: 0 2px 6px rgba(0,0,0,0.3); 1122 white-space: nowrap; 1123 transition: background 0.4s ease-out, border 0.4s ease-out; 1124 } 1125 1126 .gift-amount .wiggle-char { 1127 display: inline-block; 1128 transition: color 0.1s; 1129 } 1130 1131 /* DKK space - uses margin instead of font space character (YWFT renders space as +) */ 1132 .gift-amount .wiggle-char.dkk-space { 1133 width: 0.5em; 1134 } 1135 1136 /* Logo intensity animations - shrinks like zooming out, maintains tight shadow, adds colored moving shadows */ 1137 .gift-logo.intensity-1 { 1138 animation: logo-pulse-1 2s ease-in-out infinite, logo-shadow-1 3s ease-in-out infinite; 1139 } 1140 1141 .gift-logo.intensity-2 { 1142 animation: logo-pulse-2 1.5s ease-in-out infinite, logo-shadow-2 2s ease-in-out infinite; 1143 } 1144 1145 .gift-logo.intensity-3 { 1146 animation: logo-pulse-3 1s ease-in-out infinite, logo-shadow-3 1.5s linear infinite; 1147 } 1148 1149 .gift-logo.intensity-4 { 1150 animation: logo-shake 0.3s ease-in-out infinite, logo-shadow-4 0.8s linear infinite; 1151 } 1152 1153 /* Intensity 1: Brightest, lightest shadow */ 1154 @keyframes logo-pulse-1 { 1155 0%, 100% { opacity: 1; } 1156 50% { opacity: 1; filter: brightness(1.3); } 1157 } 1158 @keyframes logo-shadow-1 { 1159 0%, 100% { 1160 filter: drop-shadow(0 4px 8px rgba(0,0,0,0.5)) brightness(1.2); 1161 } 1162 50% { 1163 filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4)) brightness(1.3); 1164 } 1165 } 1166 1167 /* Intensity 2: Bright with medium shadow */ 1168 @keyframes logo-pulse-2 { 1169 0%, 100% { opacity: 1; } 1170 50% { opacity: 1; filter: brightness(1.15); } 1171 } 1172 @keyframes logo-shadow-2 { 1173 0%, 100% { 1174 filter: drop-shadow(0 5px 12px rgba(0,0,0,0.5)) drop-shadow(0 0 15px rgba(255,215,0,0.3)) brightness(1.1); 1175 } 1176 50% { 1177 filter: drop-shadow(0 5px 12px rgba(0,0,0,0.5)) drop-shadow(0 0 20px rgba(0,229,255,0.3)) brightness(1.15); 1178 } 1179 } 1180 1181 /* Intensity 3: Darker with heavier shadows */ 1182 @keyframes logo-pulse-3 { 1183 0%, 100% { opacity: 0.95; } 1184 50% { opacity: 1; } 1185 } 1186 @keyframes logo-shadow-3 { 1187 0% { 1188 filter: drop-shadow(0 6px 15px rgba(0,0,0,0.6)) drop-shadow(0 0 20px #ff6b6b) brightness(1); 1189 } 1190 33% { 1191 filter: drop-shadow(0 6px 15px rgba(0,0,0,0.6)) drop-shadow(0 0 20px #4ecdc4) brightness(1); 1192 } 1193 66% { 1194 filter: drop-shadow(0 6px 15px rgba(0,0,0,0.6)) drop-shadow(0 0 20px #ffd93d) brightness(1); 1195 } 1196 100% { 1197 filter: drop-shadow(0 6px 15px rgba(0,0,0,0.6)) drop-shadow(0 0 20px #ff6b6b) brightness(1); 1198 } 1199 } 1200 1201 /* Intensity 4: Super bright pink electric saturated */ 1202 @keyframes logo-shadow-4 { 1203 0% { 1204 filter: drop-shadow(0 0 25px #ff0080) drop-shadow(0 0 40px #ff00ff) brightness(1.4) saturate(1.5); 1205 } 1206 25% { 1207 filter: drop-shadow(0 0 30px #ff00ff) drop-shadow(0 0 40px #00e5ff) brightness(1.5) saturate(1.6); 1208 } 1209 50% { 1210 filter: drop-shadow(0 0 25px #00e5ff) drop-shadow(0 0 40px #ffd700) brightness(1.4) saturate(1.5); 1211 } 1212 75% { 1213 filter: drop-shadow(0 0 30px #ffd700) drop-shadow(0 0 40px #ff0080) brightness(1.5) saturate(1.6); 1214 } 1215 100% { 1216 filter: drop-shadow(0 0 25px #ff0080) drop-shadow(0 0 40px #ff00ff) brightness(1.4) saturate(1.5); 1217 } 1218 } 1219 1220 @keyframes logo-shake { 1221 0%, 100% { transform: translate(0, 0); } 1222 20% { transform: translate(-4px, 3px); } 1223 40% { transform: translate(4px, -3px); } 1224 60% { transform: translate(-3px, -4px); } 1225 80% { transform: translate(3px, 4px); } 1226 } 1227 1228 /* Vegas intensity levels for amount */ 1229 @keyframes vegas-pulse { 1230 0%, 100% { transform: scale(1); filter: brightness(1); } 1231 50% { transform: scale(1.05); filter: brightness(1.2); } 1232 } 1233 1234 @keyframes vegas-glow { 1235 0%, 100% { box-shadow: 0 0 12px rgba(255,215,0,0.6); } 1236 50% { box-shadow: 0 0 20px rgba(255,215,0,0.8); } 1237 } 1238 1239 @keyframes vegas-rainbow { 1240 0% { border-color: #ff6b6b; box-shadow: 0 0 15px #ff6b6b; } 1241 16% { border-color: #ffd93d; box-shadow: 0 0 15px #ffd93d; } 1242 33% { border-color: #6bcb77; box-shadow: 0 0 15px #6bcb77; } 1243 50% { border-color: #4ecdc4; box-shadow: 0 0 15px #4ecdc4; } 1244 66% { border-color: #9b59b6; box-shadow: 0 0 15px #9b59b6; } 1245 83% { border-color: #ff6b9d; box-shadow: 0 0 15px #ff6b9d; } 1246 100% { border-color: #ff6b6b; box-shadow: 0 0 15px #ff6b6b; } 1247 } 1248 1249 @keyframes vegas-shake { 1250 0%, 100% { transform: translate(-0.5em, -50%) scale(1.6); } 1251 10%, 30%, 50%, 70%, 90% { transform: translate(calc(-0.5em - 2px), -50%) scale(1.6); } 1252 20%, 40%, 60%, 80% { transform: translate(calc(-0.5em + 2px), -50%) scale(1.6); } 1253 } 1254 1255 .gift-amount.intensity-1 { 1256 top: 15%; 1257 right: -25%; 1258 transform: translateY(-50%); 1259 color: #fff; 1260 background: linear-gradient(135deg, #1a4a2a 0%, #0a2a1a 100%); 1261 border-color: #3a7a4a; 1262 } 1263 1264 .gift-amount.intensity-2 { 1265 top: 15%; 1266 right: -25%; 1267 transform: translateY(-50%); 1268 color: #ffd700; 1269 background: linear-gradient(135deg, #4a3a1a 0%, #3a2a0a 100%); 1270 border-color: #8a7a3a; 1271 } 1272 1273 .gift-amount.intensity-3 { 1274 top: 15%; 1275 right: -25%; 1276 transform: translateY(-50%); 1277 color: #fff; 1278 background: linear-gradient(135deg, #5a2a2a 0%, #4a1a1a 100%); 1279 border-color: #ff6b6b; 1280 } 1281 1282 .gift-amount.intensity-4 { 1283 top: 10%; 1284 right: -30%; 1285 transform: translateY(-50%); 1286 color: #fff; 1287 font-weight: bold; 1288 text-shadow: 0 0 10px #ffd700; 1289 background: linear-gradient(135deg, #6a1a3a 0%, #4a0a2a 100%); 1290 border-color: #ff0080; 1291 } 1292 1293 @keyframes price-blink-insane { 1294 0% { 1295 background: linear-gradient(135deg, #ff0000 0%, #000 100%); 1296 color: #fff; 1297 text-shadow: 0 0 20px #fff, 0 0 40px #fff; 1298 font-weight: 900; 1299 } 1300 25% { 1301 background: linear-gradient(135deg, #fff 0%, #ff0080 100%); 1302 color: #000; 1303 text-shadow: 0 0 15px #ff0080, 0 0 30px #ff0080; 1304 font-weight: 900; 1305 } 1306 50% { 1307 background: linear-gradient(135deg, #000 0%, #ff0000 100%); 1308 color: #ff0; 1309 text-shadow: 0 0 25px #ff0, 0 0 50px #ff0; 1310 font-weight: 900; 1311 } 1312 75% { 1313 background: linear-gradient(135deg, #ff0080 0%, #00e5ff 100%); 1314 color: #fff; 1315 text-shadow: 0 0 20px #00e5ff, 0 0 40px #ff0080; 1316 font-weight: 900; 1317 } 1318 100% { 1319 background: linear-gradient(135deg, #ff0000 0%, #000 100%); 1320 color: #fff; 1321 text-shadow: 0 0 20px #fff, 0 0 40px #fff; 1322 font-weight: 900; 1323 } 1324 } 1325 1326 @keyframes price-blink { 1327 0%, 100% { background: linear-gradient(135deg, #6a1a3a 0%, #4a0a2a 100%); } 1328 33% { background: linear-gradient(135deg, #ff0080 0%, #6a1a3a 100%); } 1329 66% { background: linear-gradient(135deg, #fff 0%, #ff0080 100%); } 1330 } 1331 1332 @keyframes vegas-shake-mild { 1333 0%, 100% { transform: translateY(-50%) rotate(0deg); } 1334 25% { transform: translateY(calc(-50% - 1px)) rotate(0.5deg); } 1335 50% { transform: translateY(calc(-50% + 1px)) rotate(-0.5deg); } 1336 75% { transform: translateY(calc(-50% + 1px)) rotate(0.3deg); } 1337 } 1338 1339 @keyframes vegas-shake-wild { 1340 0%, 100% { transform: translateY(-50%) rotate(0deg); } 1341 10% { transform: translate(3px, calc(-50% - 2px)) rotate(2deg); } 1342 20% { transform: translate(-4px, calc(-50% + 3px)) rotate(-3deg); } 1343 30% { transform: translate(2px, calc(-50% - 4px)) rotate(2deg); } 1344 40% { transform: translate(-3px, calc(-50% + 2px)) rotate(-2deg); } 1345 50% { transform: translate(4px, calc(-50% + 3px)) rotate(3deg); } 1346 60% { transform: translate(-2px, calc(-50% - 3px)) rotate(-1deg); } 1347 70% { transform: translate(3px, calc(-50% + 4px)) rotate(2deg); } 1348 80% { transform: translate(-4px, calc(-50% - 2px)) rotate(-3deg); } 1349 90% { transform: translate(2px, calc(-50% + 2px)) rotate(1deg); } 1350 } 1351 1352 @keyframes vegas-rainbow-fast { 1353 0% { border-color: #ff6b6b; box-shadow: 0 0 20px #ff6b6b; } 1354 14% { border-color: #ffd93d; box-shadow: 0 0 20px #ffd93d; } 1355 28% { border-color: #6bcb77; box-shadow: 0 0 20px #6bcb77; } 1356 42% { border-color: #4ecdc4; box-shadow: 0 0 20px #4ecdc4; } 1357 57% { border-color: #9b59b6; box-shadow: 0 0 20px #9b59b6; } 1358 71% { border-color: #ff6b9d; box-shadow: 0 0 20px #ff6b9d; } 1359 85% { border-color: #00e5ff; box-shadow: 0 0 20px #00e5ff; } 1360 100% { border-color: #ff6b6b; box-shadow: 0 0 20px #ff6b6b; } 1361 } 1362 1363 /* Flames effect for max monthly ($500/mo) */ 1364 .gift-amount.flames::before { 1365 content: '🔥'; 1366 position: absolute; 1367 top: -1.2em; 1368 left: 0; 1369 font-size: 0.8em; 1370 } 1371 1372 .gift-amount.flames::after { 1373 content: '🔥'; 1374 position: absolute; 1375 top: -1.1em; 1376 right: 0; 1377 font-size: 0.7em; 1378 } 1379 1380 @keyframes flame-dance { 1381 0% { transform: translateY(0) scale(1); opacity: 0.9; } 1382 100% { transform: translateY(-3px) scale(1.1); opacity: 1; } 1383 } 1384 1385 /* Lightning effect for max one-time ($2048) */ 1386 .gift-amount.lightning { 1387 border-color: #00ffff; 1388 box-shadow: 0 0 10px rgba(0, 255, 255, 0.3); 1389 } 1390 1391 @keyframes lightning-flash { 1392 0%, 89%, 100% { 1393 box-shadow: 0 2px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.1); 1394 } 1395 90%, 92% { 1396 box-shadow: 0 0 30px #fff, 0 0 60px #00ffff, 0 0 90px #fff, inset 0 0 20px rgba(255,255,255,0.5); 1397 } 1398 94%, 96% { 1399 box-shadow: 0 2px 8px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.1); 1400 } 1401 97%, 98% { 1402 box-shadow: 0 0 40px #fff, 0 0 80px #ffff00, 0 0 120px #fff, inset 0 0 30px rgba(255,255,255,0.8); 1403 } 1404 } 1405 1406 .gift-amount.lightning.flames::before { 1407 content: '⚡'; 1408 font-size: 1em; 1409 top: -1.3em; 1410 } 1411 1412 .gift-amount.lightning.flames::after { 1413 content: '⚡'; 1414 font-size: 0.9em; 1415 top: -1.2em; 1416 } 1417 1418 @keyframes lightning-bolt { 1419 0%, 100% { opacity: 0.7; transform: translateY(0) rotate(-5deg); } 1420 50% { opacity: 1; transform: translateY(-2px) rotate(5deg); } 1421 } 1422 1423 /* Power of 2 special color effects */ 1424 .gift-amount.pow2-1 { color: #88ff88; text-shadow: 0 0 8px #00ff00; } 1425 .gift-amount.pow2-2 { color: #00ffcc; text-shadow: 0 0 8px #00ffcc; } 1426 .gift-amount.pow2-4 { color: #00ccff; text-shadow: 0 0 10px #00ccff; } 1427 .gift-amount.pow2-8 { color: #6699ff; text-shadow: 0 0 10px #6699ff; } 1428 .gift-amount.pow2-16 { color: #9966ff; text-shadow: 0 0 12px #9966ff; } 1429 .gift-amount.pow2-32 { color: #cc66ff; text-shadow: 0 0 12px #cc66ff; } 1430 .gift-amount.pow2-64 { color: #ff66cc; text-shadow: 0 0 14px #ff66cc; } 1431 .gift-amount.pow2-128 { color: #ff6699; text-shadow: 0 0 14px #ff6699; } 1432 .gift-amount.pow2-256 { color: #ff9933; text-shadow: 0 0 16px #ff9933; } 1433 .gift-amount.pow2-512 { color: #ffcc00; text-shadow: 0 0 16px #ffcc00; } 1434 .gift-amount.pow2-1024 { color: #ffff00; text-shadow: 0 0 20px #ffff00, 0 0 30px #ffaa00; } 1435 .gift-amount.pow2-2048 { 1436 color: #ffffff; 1437 text-shadow: 0 0 20px #fff, 0 0 40px #ff00ff, 0 0 60px #00ffff; 1438 animation: pow2-max 0.5s linear infinite; 1439 } 1440 1441 @keyframes pow2-max { 1442 0%, 100% { text-shadow: 0 0 20px #fff, 0 0 40px #ff00ff, 0 0 60px #00ffff; } 1443 25% { text-shadow: 0 0 20px #fff, 0 0 40px #00ffff, 0 0 60px #ffff00; } 1444 50% { text-shadow: 0 0 20px #fff, 0 0 40px #ffff00, 0 0 60px #ff00ff; } 1445 75% { text-shadow: 0 0 20px #fff, 0 0 40px #ff00ff, 0 0 60px #00ff00; } 1446 } 1447 1448 /* Currency conversion display - to right of price */ 1449 .gift-conversion { 1450 position: absolute; 1451 top: -0.5em; 1452 right: -7em; 1453 font-family: 'YWFTProcessing-Regular', var(--mono); 1454 font-size: 1.1em; 1455 color: var(--cyan); 1456 white-space: nowrap; 1457 } 1458 1459 .gift-conversion:empty { 1460 display: none; 1461 } 1462 1463 .gift-conversion span { 1464 margin: 0; 1465 } 1466 1467 .gift-controls { 1468 display: flex; 1469 flex-direction: column; 1470 gap: 0; 1471 position: relative; 1472 overflow: visible; 1473 } 1474 1475 .gift-controls input[type="range"] { 1476 -webkit-appearance: none; 1477 width: 100%; 1478 height: 8px; 1479 background: linear-gradient(90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3, #54a0ff, #5f27cd, #00d2d3); 1480 border-radius: 0; 1481 outline: none; 1482 cursor: pointer; 1483 margin: 0; 1484 padding: 0 20px; 1485 box-sizing: content-box; 1486 display: block; 1487 position: relative; 1488 z-index: 1000; 1489 overflow: visible; 1490 } 1491 1492 .gift-controls input[type="range"]::-webkit-slider-thumb { 1493 -webkit-appearance: none; 1494 width: 26px; 1495 height: 26px; 1496 background: var(--green); 1497 border-radius: 50%; 1498 cursor: grab; 1499 border: 2px solid rgba(0,0,0,0.35); 1500 box-shadow: 0 2px 8px rgba(0,0,0,0.5), inset 0 2px 4px rgba(255,255,255,0.3); 1501 transition: background 0.2s ease, transform 0.1s ease, box-shadow 0.1s ease; 1502 transform: scale(1.6); 1503 transform-origin: center center; 1504 position: relative; 1505 z-index: 1001; 1506 } 1507 1508 .gift-controls input[type="range"]::-webkit-slider-thumb:hover { 1509 box-shadow: 0 3px 10px rgba(0,0,0,0.6), inset 0 2px 4px rgba(255,255,255,0.3); 1510 transform: scale(1.7); 1511 } 1512 1513 .gift-controls input[type="range"]::-webkit-slider-thumb:active { 1514 cursor: grabbing; 1515 transform: scale(1.8); 1516 box-shadow: 0 4px 12px rgba(0,0,0,0.7), inset 0 2px 4px rgba(255,255,255,0.3); 1517 } 1518 1519 .gift-controls input[type="range"]::-moz-range-thumb { 1520 width: 26px; 1521 height: 26px; 1522 background: var(--green); 1523 border-radius: 50%; 1524 cursor: grab; 1525 border: 2px solid rgba(0,0,0,0.35); 1526 box-shadow: 0 2px 8px rgba(0,0,0,0.5), inset 0 2px 4px rgba(255,255,255,0.3); 1527 transition: background 0.2s ease, transform 0.1s ease, box-shadow 0.1s ease; 1528 transform: scale(1.6); 1529 transform-origin: center center; 1530 position: relative; 1531 z-index: 1001; 1532 } 1533 1534 .gift-controls input[type="range"]::-moz-range-thumb:hover { 1535 box-shadow: 0 3px 10px rgba(0,0,0,0.6), inset 0 2px 4px rgba(255,255,255,0.3); 1536 transform: scale(1.5); 1537 } 1538 1539 .gift-controls input[type="range"]::-moz-range-thumb:active { 1540 cursor: grabbing; 1541 transform: scale(1.1); 1542 box-shadow: 0 4px 12px rgba(0,0,0,0.7), inset 0 2px 4px rgba(255,255,255,0.3); 1543 } 1544 1545 /* Gold thumb for monthly mode */ 1546 .gift-widget.monthly-mode input[type="range"]::-webkit-slider-thumb { 1547 background: var(--gold); 1548 } 1549 1550 .gift-widget.monthly-mode input[type="range"]::-moz-range-thumb { 1551 background: var(--gold); 1552 } 1553 1554 @keyframes thumbPulseDefault { 1555 0%, 100% { 1556 transform: scale(1); 1557 box-shadow: 0 2px 6px rgba(0,0,0,0.3), 0 0 0 rgba(78, 205, 196, 0); 1558 } 1559 50% { 1560 transform: scale(1.15); 1561 box-shadow: 0 2px 8px rgba(0,0,0,0.4), 0 0 12px rgba(78, 205, 196, 0.5); 1562 } 1563 } 1564 1565 /* Slider intensity states */ 1566 .gift-controls input[type="range"].intensity-1 { 1567 background: linear-gradient(90deg, var(--cyan) 0%, #3dbda8 100%); 1568 } 1569 .gift-controls input[type="range"].intensity-1::-webkit-slider-thumb { 1570 background: #4ecdc4; 1571 box-shadow: 0 0 8px rgba(78, 205, 196, 0.5); 1572 } 1573 .gift-controls input[type="range"].intensity-1::-moz-range-thumb { 1574 background: #4ecdc4; 1575 box-shadow: 0 0 8px rgba(78, 205, 196, 0.5); 1576 } 1577 1578 .gift-controls input[type="range"].intensity-2 { 1579 background: linear-gradient(90deg, #4ecdc4 0%, #6bde8a 50%, #a8e063 100%); 1580 } 1581 .gift-controls input[type="range"].intensity-2::-webkit-slider-thumb { 1582 background: #7de88a; 1583 transform: scale(1.1); 1584 box-shadow: 0 0 12px rgba(125, 232, 138, 0.6); 1585 } 1586 .gift-controls input[type="range"].intensity-2::-moz-range-thumb { 1587 background: #7de88a; 1588 transform: scale(1.1); 1589 box-shadow: 0 0 12px rgba(125, 232, 138, 0.6); 1590 } 1591 1592 .gift-controls input[type="range"].intensity-3 { 1593 background: linear-gradient(90deg, #6bde8a 0%, #ffd93d 50%, #ffb347 100%); 1594 } 1595 .gift-controls input[type="range"].intensity-3::-webkit-slider-thumb { 1596 background: #ffc857; 1597 transform: scale(1.2); 1598 box-shadow: 0 0 16px rgba(255, 200, 87, 0.7); 1599 } 1600 .gift-controls input[type="range"].intensity-3::-moz-range-thumb { 1601 background: #ffc857; 1602 transform: scale(1.2); 1603 box-shadow: 0 0 16px rgba(255, 200, 87, 0.7); 1604 } 1605 1606 .gift-controls input[type="range"].intensity-4 { 1607 background: linear-gradient(90deg, #ffb347 0%, #ff6b6b 50%, #ff4757 100%); 1608 animation: sliderPulse 0.5s ease-in-out infinite alternate; 1609 } 1610 .gift-controls input[type="range"].intensity-4::-webkit-slider-thumb { 1611 background: linear-gradient(135deg, #ff6b6b 0%, #ff4757 100%); 1612 transform: scale(1.35); 1613 box-shadow: 0 0 20px rgba(255, 71, 87, 0.8), 0 0 40px rgba(255, 107, 107, 0.4); 1614 animation: thumbGlow 0.3s ease-in-out infinite alternate; 1615 } 1616 .gift-controls input[type="range"].intensity-4::-moz-range-thumb { 1617 background: linear-gradient(135deg, #ff6b6b 0%, #ff4757 100%); 1618 transform: scale(1.35); 1619 box-shadow: 0 0 20px rgba(255, 71, 87, 0.8), 0 0 40px rgba(255, 107, 107, 0.4); 1620 animation: thumbGlow 0.3s ease-in-out infinite alternate; 1621 } 1622 1623 @keyframes sliderPulse { 1624 from { filter: brightness(1); } 1625 to { filter: brightness(1.15); } 1626 } 1627 1628 @keyframes thumbGlow { 1629 from { box-shadow: 0 0 20px rgba(255, 71, 87, 0.8), 0 0 40px rgba(255, 107, 107, 0.4); } 1630 to { box-shadow: 0 0 25px rgba(255, 71, 87, 1), 0 0 50px rgba(255, 107, 107, 0.6); } 1631 } 1632 1633 /* Gift mode checkbox (monthly repeat) */ 1634 .gift-monthly-check { 1635 display: flex; 1636 align-items: center; 1637 gap: 8px; 1638 padding: 0.6em 0 0.3em; 1639 font-size: 0.85em; 1640 color: var(--gold); 1641 cursor: pointer; 1642 user-select: none; 1643 } 1644 1645 .gift-monthly-check input[type="checkbox"] { 1646 width: 18px; 1647 height: 18px; 1648 accent-color: var(--gold); 1649 cursor: pointer; 1650 /* Dark checkbox appearance */ 1651 appearance: none; 1652 -webkit-appearance: none; 1653 background: rgba(0, 0, 0, 0.4); 1654 border: 1px solid rgba(255, 255, 255, 0.2); 1655 border-radius: 3px; 1656 position: relative; 1657 } 1658 1659 .gift-monthly-check input[type="checkbox"]:checked { 1660 background: var(--gold); 1661 border-color: var(--gold); 1662 } 1663 1664 .gift-monthly-check input[type="checkbox"]:checked::after { 1665 content: '✓'; 1666 position: absolute; 1667 top: 50%; 1668 left: 50%; 1669 transform: translate(-50%, -50%); 1670 color: #000; 1671 font-size: 12px; 1672 font-weight: bold; 1673 } 1674 1675 .gift-monthly-check:hover { 1676 color: var(--text); 1677 } 1678 1679 .gift-monthly-check.checked { 1680 color: var(--gold); 1681 } 1682 1683 /* Monthly mode styling */ 1684 .gift-amount { 1685 position: absolute; 1686 } 1687 1688 /* Tax deductible note and monthly checkbox area */ 1689 .gift-monthly-row { 1690 display: flex; 1691 align-items: center; 1692 justify-content: space-between; 1693 width: 100%; 1694 margin-top: 0.4em; 1695 } 1696 1697 /* Container for monthly checkbox + cancel link */ 1698 .gift-monthly-stack { 1699 display: flex; 1700 flex-direction: row; 1701 align-items: center; 1702 justify-content: flex-start; 1703 gap: 0.6em; 1704 } 1705 1706 /* Subscribe as user indicator */ 1707 .subscribe-as { 1708 font-size: 0.65em; 1709 color: var(--dim); 1710 opacity: 0.7; 1711 padding-left: 0.1em; 1712 } 1713 1714 .subscribe-as-handle { 1715 color: var(--gold); 1716 } 1717 1718 /* Login prompt modal (native dialog) */ 1719 .login-modal { 1720 background: #0a0a0f; 1721 border: 1px solid rgba(255, 255, 255, 0.15); 1722 border-radius: 4px; 1723 padding: 1.5em 2em; 1724 max-width: 360px; 1725 text-align: center; 1726 color: var(--text); 1727 font-family: var(--mono); 1728 box-shadow: 1729 0 0 0 1px rgba(205, 92, 155, 0.3), 1730 0 8px 32px rgba(0, 0, 0, 0.5); 1731 } 1732 1733 .login-modal::backdrop { 1734 background: rgba(0, 0, 0, 0.8); 1735 backdrop-filter: blur(4px); 1736 } 1737 1738 /* Login button blink animation */ 1739 .footer-login-btn.blinking { 1740 animation: login-btn-blink 0.5s ease-in-out 4; 1741 } 1742 1743 @keyframes login-btn-blink { 1744 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 215, 0, 0); } 1745 50% { box-shadow: 0 0 15px 5px rgba(255, 215, 0, 0.7); } 1746 } 1747 1748 .login-modal-icon { 1749 font-size: 2em; 1750 margin-bottom: 0.4em; 1751 filter: grayscale(0.2); 1752 } 1753 1754 .login-modal-title { 1755 font-size: 1em; 1756 font-weight: 600; 1757 margin-bottom: 0.6em; 1758 color: var(--pink); 1759 font-family: var(--mono); 1760 text-transform: uppercase; 1761 letter-spacing: 0.05em; 1762 } 1763 1764 .login-modal-text { 1765 font-size: 0.85em; 1766 color: rgba(255, 255, 255, 0.6); 1767 margin-bottom: 1.4em; 1768 line-height: 1.5; 1769 font-family: var(--mono); 1770 } 1771 1772 .login-modal-buttons { 1773 display: flex; 1774 gap: 0.6em; 1775 justify-content: center; 1776 } 1777 1778 .login-modal-btn { 1779 padding: 0.5em 1.2em; 1780 border-radius: 3px; 1781 font-size: 0.85em; 1782 cursor: pointer; 1783 transition: all 0.15s ease; 1784 border: 1px solid transparent; 1785 font-family: var(--mono); 1786 text-transform: uppercase; 1787 letter-spacing: 0.03em; 1788 } 1789 1790 .login-modal-btn.primary { 1791 background: var(--gold); 1792 color: #000; 1793 font-weight: 600; 1794 border-color: var(--gold); 1795 } 1796 1797 .login-modal-btn.primary:hover { 1798 background: #f5d34a; 1799 border-color: #f5d34a; 1800 transform: translateY(-1px); 1801 } 1802 1803 .login-modal-btn.secondary { 1804 background: transparent; 1805 color: rgba(255, 255, 255, 0.5); 1806 border: 1px solid rgba(255, 255, 255, 0.2); 1807 } 1808 1809 .login-modal-btn.secondary:hover { 1810 background: rgba(255, 255, 255, 0.05); 1811 color: rgba(255, 255, 255, 0.8); 1812 border-color: rgba(255, 255, 255, 0.3); 1813 } 1814 1815 .cancel-subscription-link { 1816 font-size: 0.85em; 1817 color: var(--dim); 1818 opacity: 0.5; 1819 text-decoration: none; 1820 align-self: center; 1821 padding-top: 0.35em; 1822 margin-left: 0.3em; 1823 } 1824 1825 .cancel-subscription-link:hover { 1826 opacity: 1; 1827 text-decoration: underline; 1828 } 1829 1830 /* Cancel link styling - below Monthly */ 1831 .gift-monthly-stack .cancel-subscription-link::before { 1832 content: none; 1833 } 1834 1835 .tax-note { 1836 font-size: 0.85em; 1837 color: var(--dim); 1838 text-align: right; 1839 align-self: flex-start; 1840 margin-top: 0.8em; 1841 } 1842 1843 .tax-note .faded { 1844 opacity: 0.5; 1845 } 1846 1847 .tax-note a { 1848 color: var(--gold); 1849 text-decoration: none; 1850 } 1851 1852 .tax-note a:hover { 1853 text-decoration: underline; 1854 } 1855 1856 /* Bottom row: monthly checkbox left, fiat note right */ 1857 .gift-bottom-row { 1858 display: flex; 1859 justify-content: space-between; 1860 align-items: center; 1861 padding: 0.8em 0 0; 1862 } 1863 1864 .gift-bottom-row .gift-monthly-check { 1865 padding: 0; 1866 } 1867 1868 .gift-bottom-row .fiat-note { 1869 font-size: 0.7em; 1870 color: var(--dim); 1871 text-align: right; 1872 margin: 0; 1873 } 1874 1875 .gift-btn { 1876 font-family: 'YWFTProcessing-Regular', sans-serif; 1877 font-size: 1.4em; 1878 padding: 0.8em 1.5em; 1879 background: linear-gradient(180deg, var(--green) 0%, #3a8a4a 100%); 1880 color: white; 1881 border: 2px solid #5ab06a; 1882 border-radius: 0 0 8px 8px; 1883 cursor: pointer; 1884 width: 100%; 1885 transition: all 0.15s; 1886 font-weight: normal; 1887 letter-spacing: 0.02em; 1888 box-shadow: 0 4px 12px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.2); 1889 text-shadow: 0 1px 2px rgba(0,0,0,0.3); 1890 margin-top: 0; 1891 } 1892 1893 .gift-btn .price { 1894 font-weight: bold; 1895 } 1896 1897 .gift-btn:hover { 1898 background: linear-gradient(180deg, var(--pink) 0%, #c83a7a 100%); 1899 border-color: #ff8ab0; 1900 transform: translateY(-1px); 1901 box-shadow: 0 6px 16px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.2); 1902 } 1903 1904 .gift-btn:active { 1905 transform: translateY(1px); 1906 box-shadow: 0 2px 8px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1); 1907 } 1908 1909 .gift-btn:disabled { 1910 opacity: 0.7; 1911 cursor: wait; 1912 } 1913 1914 /* Yellow button when monthly mode is active */ 1915 .gift-widget.monthly-mode .gift-btn { 1916 background: linear-gradient(180deg, var(--gold) 0%, #c9a830 100%); 1917 border-color: #ffe066; 1918 color: #1a1a2e; 1919 text-shadow: 0 1px 0 rgba(255,255,255,0.3); 1920 } 1921 1922 .gift-widget.monthly-mode .gift-btn:hover { 1923 background: linear-gradient(180deg, #ffe066 0%, var(--gold) 100%); 1924 border-color: #fff099; 1925 } 1926 1927 /* Yellow/gold price display when monthly mode is active */ 1928 .gift-widget.monthly-mode .gift-amount { 1929 color: var(--gold); 1930 border-color: var(--gold); 1931 background: linear-gradient(135deg, #2a2a1a 0%, #1a1a0a 100%); 1932 } 1933 1934 /* Crypto controls - match fiat layout */ 1935 .crypto-controls { 1936 display: flex; 1937 flex-direction: column; 1938 gap: 0; 1939 padding: 1em; 1940 background: var(--box-bg); 1941 border: 1px solid var(--box-border); 1942 border-top: none; 1943 border-radius: 0 0 8px 8px; 1944 } 1945 1946 /* Compact crypto header with tiny logo */ 1947 .crypto-compact .gift-visual { 1948 display: none; 1949 } 1950 1951 .crypto-header { 1952 display: flex; 1953 align-items: center; 1954 gap: 0.5em; 1955 padding: 0.6em 1em; 1956 background: linear-gradient(135deg, rgba(106, 74, 138, 0.2) 0%, rgba(0, 255, 255, 0.05) 100%); 1957 border: 1px solid var(--box-border); 1958 border-radius: 0; 1959 } 1960 1961 .crypto-logo-tiny { 1962 width: 24px; 1963 height: 24px; 1964 } 1965 1966 .crypto-title { 1967 font-family: 'YWFTProcessing-Regular', sans-serif; 1968 font-size: 1em; 1969 color: #a78bfa; 1970 } 1971 1972 .crypto-subtitle { 1973 font-family: var(--mono); 1974 font-size: 0.8em; 1975 color: var(--dim); 1976 margin-left: 0.8em; 1977 } 1978 1979 .crypto-compact .crypto-controls { 1980 border-radius: 0 0 8px 8px; 1981 } 1982 1983 /* Stacked crypto addresses for all-in-one view */ 1984 .crypto-controls.crypto-all { 1985 display: grid; 1986 grid-template-columns: repeat(3, 1fr); 1987 gap: 0; 1988 padding: 0; 1989 background: transparent; 1990 border: none; 1991 } 1992 1993 .crypto-controls.crypto-all .crypto-address { 1994 padding: 1.2em 0.8em; 1995 display: flex; 1996 flex-direction: column; 1997 align-items: center; 1998 justify-content: center; 1999 text-align: center; 2000 border: none; 2001 border-radius: 0; 2002 min-height: 160px; 2003 } 2004 2005 .crypto-controls.crypto-all .crypto-address:first-child { 2006 border-radius: 0 0 0 8px; 2007 } 2008 2009 .crypto-controls.crypto-all .crypto-address:last-child { 2010 border-radius: 0 0 8px 0; 2011 } 2012 2013 /* XTZ - Purple */ 2014 .crypto-controls.crypto-all .crypto-address[data-crypto="xtz"] { 2015 background: linear-gradient(180deg, rgba(138, 74, 255, 0.3) 0%, rgba(100, 50, 180, 0.4) 100%); 2016 } 2017 2018 /* ETH - Blue */ 2019 .crypto-controls.crypto-all .crypto-address[data-crypto="eth"] { 2020 background: linear-gradient(180deg, rgba(74, 138, 255, 0.3) 0%, rgba(50, 100, 180, 0.4) 100%); 2021 } 2022 2023 /* BTC - Orange */ 2024 .crypto-controls.crypto-all .crypto-address[data-crypto="btc"] { 2025 background: linear-gradient(180deg, rgba(255, 160, 50, 0.3) 0%, rgba(200, 120, 30, 0.4) 100%); 2026 } 2027 2028 .crypto-controls.crypto-all .crypto-label { 2029 margin-bottom: 0.3em; 2030 } 2031 2032 .crypto-controls.crypto-all .crypto-symbol { 2033 font-size: 2.8em; 2034 line-height: 1; 2035 } 2036 2037 .crypto-controls.crypto-all .crypto-symbol .crypto-icon { 2038 width: 48px; 2039 height: 48px; 2040 vertical-align: middle; 2041 filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); 2042 } 2043 2044 .crypto-controls.crypto-all .crypto-name { 2045 font-size: 0.75em; 2046 opacity: 0.7; 2047 } 2048 2049 .crypto-controls.crypto-all .crypto-balance { 2050 font-size: 1.1em; 2051 margin-bottom: 0.3em; 2052 } 2053 2054 .crypto-controls.crypto-all .crypto-addr { 2055 font-size: 0.8em; 2056 padding: 0.5em 0.6em; 2057 background: rgba(0, 0, 0, 0.3); 2058 border: none; 2059 } 2060 2061 .crypto-controls.crypto-all .crypto-copy-hint { 2062 font-size: 0.65em; 2063 margin-top: 0.4em; 2064 opacity: 0.6; 2065 } 2066 2067 @media (max-width: 600px) { 2068 .crypto-controls.crypto-all { 2069 grid-template-columns: 1fr; 2070 max-width: 100%; 2071 } 2072 .crypto-controls.crypto-all .crypto-address:first-child { 2073 border-radius: 0; 2074 } 2075 .crypto-controls.crypto-all .crypto-address:last-child { 2076 border-radius: 0 0 8px 8px; 2077 } 2078 .crypto-controls.crypto-all .crypto-address { 2079 min-height: 120px; 2080 padding: 0.8em 0.5em; 2081 } 2082 .crypto-controls.crypto-all .crypto-addr { 2083 font-size: 0.7em; 2084 word-break: break-all; 2085 } 2086 .crypto-header { 2087 flex-wrap: wrap; 2088 } 2089 .crypto-subtitle { 2090 display: none; 2091 } 2092 } 2093 2094 .crypto-address { 2095 background: linear-gradient(135deg, rgba(100, 255, 150, 0.08) 0%, rgba(0, 255, 255, 0.05) 100%); 2096 border: 2px solid rgba(100, 255, 150, 0.3); 2097 padding: 1.2em 1.4em; 2098 border-radius: 6px; 2099 cursor: pointer; 2100 transition: all 0.2s ease; 2101 position: relative; 2102 overflow: hidden; 2103 } 2104 2105 2106 2107 .crypto-address:hover { 2108 border-color: var(--green); 2109 background: linear-gradient(135deg, rgba(100, 255, 150, 0.15) 0%, rgba(0, 255, 255, 0.1) 100%); 2110 transform: scale(1.01); 2111 } 2112 2113 .crypto-address:active { 2114 transform: scale(0.99); 2115 } 2116 2117 .crypto-address.copied { 2118 background: var(--green); 2119 border-color: var(--green); 2120 } 2121 2122 .crypto-address.copied::before { 2123 animation: none; 2124 } 2125 2126 .crypto-address.copied .crypto-label, 2127 .crypto-address.copied .crypto-addr, 2128 .crypto-address.copied .crypto-copy-hint, 2129 .crypto-address.copied .crypto-balance { 2130 color: var(--bg); 2131 } 2132 2133 .crypto-label { 2134 display: flex; 2135 flex-direction: column; 2136 align-items: center; 2137 font-family: var(--mono); 2138 font-weight: 600; 2139 color: var(--cyan); 2140 margin-bottom: 0.4em; 2141 text-align: center; 2142 } 2143 2144 .crypto-label .crypto-symbol { 2145 font-size: 2.2em; 2146 line-height: 1; 2147 margin-bottom: 0.1em; 2148 } 2149 2150 .crypto-label .crypto-name { 2151 font-size: 0.9em; 2152 opacity: 0.8; 2153 } 2154 2155 .crypto-balance { 2156 display: block; 2157 font-family: var(--mono); 2158 font-size: 1.1em; 2159 font-weight: 600; 2160 color: var(--gold); 2161 text-align: center; 2162 margin-bottom: 0.6em; 2163 min-height: 1.4em; 2164 } 2165 2166 .crypto-balance .loading { 2167 color: var(--dim); 2168 font-size: 0.9em; 2169 } 2170 2171 .crypto-balance .usd-equiv { 2172 font-size: 0.8em; 2173 color: var(--dim); 2174 font-weight: normal; 2175 } 2176 2177 .crypto-addr { 2178 display: block; 2179 font-family: var(--mono); 2180 font-size: 0.95em; 2181 letter-spacing: 0.03em; 2182 color: #00ff88; 2183 text-align: center; 2184 padding: 0.8em 0.6em; 2185 background: rgba(0, 20, 10, 0.8); 2186 border-radius: 4px; 2187 user-select: all; 2188 cursor: pointer; 2189 text-shadow: 0 0 8px #00ff8855; 2190 border: 1px solid #00ff8833; 2191 } 2192 2193 .crypto-addr .addr-dots { 2194 color: #00aa66; 2195 letter-spacing: 0.1em; 2196 } 2197 2198 .crypto-addr:hover { 2199 background: rgba(0, 40, 20, 0.9); 2200 color: #00ffaa; 2201 text-shadow: 0 0 12px #00ffaa88; 2202 } 2203 2204 .crypto-copy-hint { 2205 display: block; 2206 font-size: 0.85em; 2207 color: var(--gold); 2208 margin-top: 0.6em; 2209 text-align: center; 2210 font-weight: 500; 2211 } 2212 2213 .crypto-address:hover .crypto-copy-hint { 2214 color: var(--green); 2215 } 2216 2217 .crypto-funds-header { 2218 text-align: center; 2219 padding: 0.6em 1em; 2220 background: linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(100, 255, 150, 0.05) 100%); 2221 border-bottom: 1px solid var(--box-border); 2222 font-size: 0.85em; 2223 color: var(--dim); 2224 } 2225 2226 .crypto-funds-header strong { 2227 color: var(--gold); 2228 } 2229 2230 .crypto-amount { 2231 background: linear-gradient(135deg, #3a2a5a 0%, #2a1a4a 100%); 2232 border-color: #6a4a8a; 2233 } 2234 2235 .crypto-usd { 2236 position: absolute; 2237 bottom: 0.5em; 2238 right: 0.5em; 2239 font-family: var(--mono); 2240 font-size: 0.85em; 2241 color: var(--gold); 2242 opacity: 0.9; 2243 } 2244 2245 .gift-widget.crypto .gift-visual { 2246 background: linear-gradient(135deg, rgba(106, 74, 138, 0.2) 0%, rgba(0, 255, 255, 0.05) 100%); 2247 } 2248 2249 /* PayPal styles - similar to crypto compact layout */ 2250 .paypal-compact .gift-visual { 2251 display: none; 2252 } 2253 2254 .paypal-header { 2255 display: flex; 2256 align-items: center; 2257 gap: 0.5em; 2258 padding: 0.6em 1em; 2259 background: linear-gradient(135deg, rgba(0, 112, 186, 0.25) 0%, rgba(0, 48, 135, 0.15) 100%); 2260 border: 1px solid var(--box-border); 2261 border-radius: 0; 2262 } 2263 2264 .paypal-logo-tiny { 2265 width: 24px; 2266 height: 24px; 2267 } 2268 2269 .paypal-title { 2270 font-family: 'YWFTProcessing-Regular', sans-serif; 2271 font-size: 1em; 2272 color: #0070ba; 2273 } 2274 2275 .paypal-subtitle { 2276 font-family: var(--mono); 2277 font-size: 0.8em; 2278 color: var(--dim); 2279 margin-left: 0.8em; 2280 } 2281 2282 .paypal-controls { 2283 padding: 2em 4em; 2284 background: linear-gradient(180deg, rgba(0, 112, 186, 0.1) 0%, rgba(0, 48, 135, 0.15) 100%); 2285 border: 1px solid var(--box-border); 2286 border-top: none; 2287 border-radius: 0 0 8px 8px; 2288 display: flex; 2289 flex-direction: row; 2290 align-items: center; 2291 justify-content: center; 2292 gap: 3em; 2293 width: 100%; 2294 box-sizing: border-box; 2295 overflow: hidden; 2296 } 2297 2298 /* PayPal wrapper for centering on wide screens */ 2299 .paypal-compact { 2300 display: flex; 2301 flex-direction: column; 2302 align-items: stretch; 2303 } 2304 2305 .paypal-compact .paypal-header { 2306 width: 100%; 2307 border-radius: 0; 2308 } 2309 2310 @media (max-width: 480px) { 2311 .paypal-controls { 2312 flex-direction: column; 2313 padding: 0.8em; 2314 gap: 0.8em; 2315 max-width: 100%; 2316 } 2317 .paypal-qr-wrap { 2318 padding: 0.6em; 2319 } 2320 .paypal-qr { 2321 width: 100px; 2322 height: 100px; 2323 } 2324 .paypal-actions { 2325 width: 100%; 2326 max-width: 100%; 2327 } 2328 .paypal-link-btn { 2329 width: 100%; 2330 box-sizing: border-box; 2331 } 2332 .paypal-header { 2333 flex-wrap: wrap; 2334 } 2335 .paypal-subtitle { 2336 display: none; 2337 } 2338 .paypal-email { 2339 flex-wrap: wrap; 2340 justify-content: center; 2341 } 2342 .paypal-email-addr { 2343 font-size: 0.85em; 2344 word-break: break-all; 2345 } 2346 } 2347 2348 .paypal-qr-wrap { 2349 flex-shrink: 0; 2350 display: flex; 2351 align-items: center; 2352 justify-content: center; 2353 padding: 0.4em; 2354 background: #fff; 2355 border-radius: 6px; 2356 box-shadow: inset 0 2px 8px rgba(0,0,0,0.15); 2357 overflow: hidden; 2358 } 2359 2360 .paypal-qr { 2361 width: 160px; 2362 height: 160px; 2363 display: block; 2364 transform: scale(1.15); 2365 } 2366 2367 .paypal-actions { 2368 display: flex; 2369 flex-direction: column; 2370 gap: 1em; 2371 min-width: 280px; 2372 } 2373 2374 .paypal-link-btn { 2375 display: inline-flex; 2376 align-items: center; 2377 justify-content: center; 2378 gap: 0.6em; 2379 padding: 0.9em 1.8em; 2380 background: linear-gradient(135deg, #0070ba 0%, #003087 100%); 2381 color: white; 2382 text-decoration: none; 2383 border-radius: 24px; 2384 font-family: 'YWFTProcessing-Regular', sans-serif; 2385 font-size: 1.1em; 2386 font-weight: 600; 2387 transition: all 0.2s ease; 2388 box-shadow: 0 3px 10px rgba(0, 112, 186, 0.3); 2389 } 2390 2391 .paypal-link-btn:hover { 2392 background: linear-gradient(135deg, #0080d0 0%, #0040a0 100%); 2393 transform: translateY(-2px); 2394 box-shadow: 0 6px 20px rgba(0, 112, 186, 0.4); 2395 } 2396 2397 .paypal-link-btn:active { 2398 transform: translateY(0); 2399 } 2400 2401 .paypal-btn-icon { 2402 display: flex; 2403 align-items: center; 2404 } 2405 2406 .paypal-btn-icon svg { 2407 width: 24px; 2408 height: 24px; 2409 } 2410 2411 .paypal-email { 2412 display: flex; 2413 flex-direction: row; 2414 align-items: center; 2415 gap: 0.6em; 2416 padding: 0.8em 1.2em; 2417 background: rgba(0, 0, 0, 0.2); 2418 border-radius: 8px; 2419 cursor: pointer; 2420 transition: all 0.2s ease; 2421 } 2422 2423 .paypal-email:hover { 2424 background: rgba(0, 112, 186, 0.15); 2425 } 2426 2427 .paypal-email.copied { 2428 background: var(--green); 2429 } 2430 2431 .paypal-email.copied .paypal-email-label, 2432 .paypal-email.copied .paypal-email-addr, 2433 .paypal-email.copied .paypal-copy-hint { 2434 color: var(--bg); 2435 } 2436 2437 .paypal-email-label { 2438 font-size: 0.75em; 2439 color: var(--dim); 2440 white-space: nowrap; 2441 } 2442 2443 .paypal-email-addr { 2444 font-family: var(--mono); 2445 font-size: 0.85em; 2446 color: #0070ba; 2447 padding: 0.2em 0.5em; 2448 background: rgba(0, 112, 186, 0.1); 2449 border-radius: 3px; 2450 border: 1px solid rgba(0, 112, 186, 0.3); 2451 } 2452 2453 .paypal-copy-hint { 2454 font-size: 0.7em; 2455 color: var(--gold); 2456 opacity: 0.8; 2457 } 2458 2459 .paypal-email:hover .paypal-copy-hint { 2460 color: var(--green); 2461 opacity: 1; 2462 } 2463 2464 /* Liberapay section */ 2465 .liberapay-compact .gift-visual { 2466 display: none; 2467 } 2468 2469 .liberapay-header { 2470 display: flex; 2471 align-items: center; 2472 gap: 0.5em; 2473 padding: 0.6em 1em; 2474 background: linear-gradient(135deg, rgba(246, 201, 21, 0.25) 0%, rgba(200, 160, 0, 0.15) 100%); 2475 border: 1px solid var(--box-border); 2476 border-radius: 0; 2477 } 2478 2479 .liberapay-logo-tiny { 2480 width: 24px; 2481 height: 24px; 2482 } 2483 2484 .liberapay-title { 2485 font-family: 'YWFTProcessing-Regular', sans-serif; 2486 font-size: 1em; 2487 color: #f6c915; 2488 } 2489 2490 .liberapay-subtitle { 2491 font-family: var(--mono); 2492 font-size: 0.8em; 2493 color: var(--dim); 2494 margin-left: 0.8em; 2495 } 2496 2497 .liberapay-controls { 2498 padding: 2em 4em; 2499 background: linear-gradient(180deg, rgba(246, 201, 21, 0.1) 0%, rgba(200, 160, 0, 0.15) 100%); 2500 border: 1px solid var(--box-border); 2501 border-top: none; 2502 border-radius: 0 0 8px 8px; 2503 display: flex; 2504 flex-direction: column; 2505 align-items: center; 2506 justify-content: center; 2507 gap: 1.5em; 2508 width: 100%; 2509 box-sizing: border-box; 2510 } 2511 2512 .liberapay-compact { 2513 display: flex; 2514 flex-direction: column; 2515 align-items: stretch; 2516 } 2517 2518 .liberapay-compact .liberapay-header { 2519 width: 100%; 2520 border-radius: 0; 2521 } 2522 2523 .liberapay-link-btn { 2524 display: inline-flex; 2525 align-items: center; 2526 justify-content: center; 2527 gap: 0.6em; 2528 padding: 0.9em 1.8em; 2529 background: linear-gradient(135deg, #f6c915 0%, #d4a800 100%); 2530 color: #1a1a2e; 2531 text-decoration: none; 2532 border-radius: 24px; 2533 font-family: 'YWFTProcessing-Regular', sans-serif; 2534 font-size: 1.1em; 2535 font-weight: 600; 2536 transition: all 0.2s ease; 2537 box-shadow: 0 3px 10px rgba(246, 201, 21, 0.3); 2538 } 2539 2540 .liberapay-link-btn:hover { 2541 background: linear-gradient(135deg, #ffd630 0%, #e0b500 100%); 2542 transform: translateY(-2px); 2543 box-shadow: 0 6px 20px rgba(246, 201, 21, 0.4); 2544 } 2545 2546 .liberapay-link-btn:active { 2547 transform: translateY(0); 2548 } 2549 2550 .liberapay-desc { 2551 font-size: 0.85em; 2552 color: var(--dim); 2553 text-align: center; 2554 max-width: 360px; 2555 } 2556 2557 @media (max-width: 480px) { 2558 .liberapay-controls { 2559 padding: 1.2em 0.8em; 2560 } 2561 .liberapay-subtitle { 2562 display: none; 2563 } 2564 } 2565 2566 .crypto-slider { 2567 -webkit-appearance: none; 2568 width: 100%; 2569 height: 12px; 2570 background: linear-gradient(90deg, var(--pink) 0%, #6a4a8a 100%); 2571 border-radius: 0; 2572 outline: none; 2573 cursor: pointer; 2574 margin: 0; 2575 border-left: 1px solid var(--box-border); 2576 border-right: 1px solid var(--box-border); 2577 } 2578 2579 .crypto-slider::-webkit-slider-thumb { 2580 -webkit-appearance: none; 2581 width: 24px; 2582 height: 24px; 2583 background: var(--text); 2584 border-radius: 50%; 2585 cursor: grab; 2586 box-shadow: 0 2px 6px rgba(0,0,0,0.3); 2587 } 2588 2589 .crypto-slider::-moz-range-thumb { 2590 width: 24px; 2591 height: 24px; 2592 background: var(--text); 2593 border-radius: 50%; 2594 cursor: grab; 2595 border: none; 2596 box-shadow: 0 2px 6px rgba(0,0,0,0.3); 2597 } 2598 2599 .crypto-buttons { 2600 display: flex; 2601 gap: 0; 2602 } 2603 2604 .crypto-send { 2605 flex: 1; 2606 border-radius: 0 0 8px 8px; 2607 background: linear-gradient(135deg, #6a4a8a 0%, #4a2a6a 100%); 2608 } 2609 2610 .crypto-send:hover { 2611 background: linear-gradient(135deg, #8a6aaa 0%, #6a4a8a 100%); 2612 } 2613 2614 small { 2615 display: block; 2616 font-size: 0.7em; 2617 color: var(--dim); 2618 text-align: center; 2619 grid-column: 1 / -1; 2620 margin: 0.5em 0; 2621 } 2622 2623 hr { 2624 border: none; 2625 border-top: 1px dashed var(--box-border); 2626 grid-column: 1 / -1; 2627 margin: 0.5em 0; 2628 } 2629 2630 .digital-currency { 2631 background: var(--box-bg); 2632 border: 1px solid var(--box-border); 2633 border-radius: 4px; 2634 padding: 1em 1.2em; 2635 } 2636 2637 .digital-currency h3 { 2638 font-family: 'YWFTProcessing-Regular', sans-serif; 2639 font-size: 0.9em; 2640 margin: 0 0 0.8em 0; 2641 color: var(--cyan); 2642 } 2643 2644 .crypto-row { 2645 display: flex; 2646 gap: 1em; 2647 flex-wrap: wrap; 2648 } 2649 2650 .crypto-item { 2651 flex: 1; 2652 min-width: 140px; 2653 } 2654 2655 .link-block { 2656 background: var(--box-bg); 2657 border: 1px solid var(--box-border); 2658 border-radius: 4px; 2659 padding: 0; 2660 display: flex; 2661 flex-direction: column; 2662 aspect-ratio: 1; 2663 overflow: hidden; 2664 position: relative; 2665 } 2666 2667 .link-block strong { 2668 position: sticky; 2669 top: 0; 2670 left: 0; 2671 background: inherit; 2672 padding: 0.8em 1em; 2673 color: var(--pink); 2674 font-family: 'YWFTProcessing-Regular', sans-serif; 2675 font-size: 0.9em; 2676 z-index: 1; 2677 } 2678 2679 .link-block a { 2680 color: var(--cyan); 2681 font-family: var(--mono); 2682 font-size: 0.9em; 2683 padding: 0 1em; 2684 flex: 1; 2685 overflow: auto; 2686 } 2687 2688 .link-block .link-desc { 2689 position: sticky; 2690 bottom: 0; 2691 left: 0; 2692 background: inherit; 2693 padding: 0.8em 1em; 2694 font-size: 0.75em; 2695 color: var(--dim); 2696 z-index: 1; 2697 } 2698 2699 /* Distinct link-block colors */ 2700 .link-block.desktop-section { 2701 background: linear-gradient(135deg, #2d1b4e 0%, #1a1a2e 100%); 2702 border-color: #3d2b5e; 2703 } 2704 .link-block.desktop-section strong, 2705 .link-block.desktop-section .link-desc { background: transparent; } 2706 2707 /* Notepat - musical purple/blue */ 2708 .link-block.notepat-section { 2709 background: linear-gradient(135deg, #1a1a3e 0%, #2a1a4e 100%); 2710 border-color: #3a2a5e; 2711 } 2712 .link-block.notepat-section strong { color: #a090ff; } 2713 .link-block.notepat-section strong, 2714 .link-block.notepat-section .link-desc { background: transparent; } 2715 2716 /* Invest - announcement ticker style */ 2717 .invest-section { 2718 background: 2719 linear-gradient(135deg, rgba(10, 26, 18, 0.85) 0%, rgba(13, 32, 24, 0.9) 100%), 2720 url('https://assets.aesthetic.computer/jeffreys/jpg/IMG_2658.jpg'); 2721 background-size: cover, 200%; 2722 background-position: center; 2723 border: 1px solid rgba(78, 205, 196, 0.3); 2724 border-radius: 8px; 2725 padding: 1.2em 1.5em; 2726 overflow: visible; 2727 position: relative; 2728 aspect-ratio: 1; 2729 } 2730 2731 .invest-header { 2732 display: flex; 2733 align-items: center; 2734 gap: 0.4em; 2735 margin-bottom: 0.8em; 2736 font-family: var(--mono); 2737 font-size: 0.9em; 2738 color: #4ecdc4; 2739 letter-spacing: 0.05em; 2740 } 2741 2742 .invest-content { 2743 font-family: 'Berkeley Mono Variable', var(--mono); 2744 font-size: 0.9em; 2745 line-height: 1.7; 2746 color: rgba(255, 255, 255, 0.9); 2747 } 2748 2749 .invest-content p { 2750 margin: 0 0 1em 0; 2751 } 2752 2753 .invest-content p:last-child { 2754 margin-bottom: 0; 2755 } 2756 2757 .invest-content a { 2758 color: #4ecdc4; 2759 text-decoration: none; 2760 border-bottom: 1px solid rgba(78, 205, 196, 0.4); 2761 transition: border-color 0.2s, color 0.2s; 2762 } 2763 2764 .invest-content a:hover { 2765 color: #6eeee6; 2766 border-bottom-color: #6eeee6; 2767 } 2768 2769 .invest-content a.handle-link { 2770 color: var(--pink); 2771 border-bottom: none; 2772 } 2773 2774 .invest-content a.handle-link:hover { 2775 color: #ff90d0; 2776 } 2777 2778 .invest-content .demo-link { 2779 /* No special styling - just inherit normal link style */ 2780 } 2781 2782 .invest-section.blinking { 2783 animation: invest-blink 0.6s ease-in-out 3; 2784 } 2785 2786 @keyframes invest-blink { 2787 0%, 100% { box-shadow: 0 0 0 0 rgba(78, 205, 196, 0); } 2788 25%, 75% { box-shadow: 0 0 20px 4px rgba(78, 205, 196, 0.6); } 2789 50% { box-shadow: 0 0 30px 8px rgba(78, 205, 196, 0.8); } 2790 } 2791 2792 /* Invest suggestion styling */ 2793 .invest-suggestion { 2794 font-size: 0.8em; 2795 color: var(--gold); 2796 padding: 0.6em 0.8em; 2797 margin-top: 0.5em; 2798 background: rgba(78, 205, 196, 0.15); 2799 border: 1px solid rgba(78, 205, 196, 0.3); 2800 border-radius: 4px; 2801 text-align: center; 2802 } 2803 2804 .invest-suggestion a { 2805 color: #4ecdc4; 2806 font-weight: bold; 2807 text-decoration: underline; 2808 } 2809 2810 .invest-suggestion a:hover { 2811 color: #6eeee6; 2812 } 2813 2814 /* Mug easter egg in invest section - fully responsive */ 2815 .invest-mug { 2816 position: absolute; 2817 bottom: 5%; 2818 right: 5%; 2819 display: flex; 2820 flex-direction: column; 2821 align-items: center; 2822 gap: 0; 2823 text-decoration: none; 2824 opacity: 1; 2825 transition: transform 0.3s; 2826 width: 30%; 2827 max-width: 140px; 2828 } 2829 .invest-mug:hover { 2830 transform: scale(1.02); 2831 text-decoration: none !important; 2832 } 2833 .invest-mug-icon { 2834 font-size: 2.5em; 2835 filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); 2836 } 2837 .invest-mug-image { 2838 width: 100%; 2839 height: auto; 2840 aspect-ratio: 1; 2841 object-fit: contain; 2842 filter: drop-shadow(0 3px 12px rgba(0,0,0,0.6)) brightness(1.1); 2843 display: none; /* Hidden until loaded */ 2844 } 2845 .invest-mug-caption { 2846 font-family: var(--mono); 2847 font-size: clamp(0.45em, 2.5vw, 0.85em); 2848 letter-spacing: 0.02em; 2849 white-space: normal; 2850 text-align: center; 2851 line-height: 1.3; 2852 position: relative; 2853 margin-top: -8%; 2854 z-index: 1; 2855 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.7); 2856 max-width: 100%; 2857 } 2858 .invest-mug-caption .mug-color { 2859 color: rgba(255, 255, 255, 1); 2860 } 2861 .invest-mug-caption .mug-of { 2862 color: rgba(200, 200, 200, 0.9); 2863 } 2864 .invest-mug-caption .mug-code { 2865 color: rgba(150, 200, 255, 1); 2866 } 2867 .invest-mug-caption .mug-via { 2868 color: rgba(255, 200, 100, 1); 2869 display: block; 2870 } 2871 /* Character-level syntax highlighting */ 2872 .invest-mug-caption .char-hash { color: #00bcd4; } /* cyan for # */ 2873 .invest-mug-caption .char-dollar { color: #ffd700; } /* gold for $ */ 2874 .invest-mug-caption .char-code { color: #87ceeb; } /* light blue for code chars */ 2875 .invest-mug-caption .char-via { color: #00e5ff; } /* cyan for via identifier */ 2876 2877 /* Container query for smaller invest boxes (e.g., 315px) */ 2878 .invest-section { 2879 container-type: inline-size; 2880 container-name: invest; 2881 } 2882 2883 @container invest (max-width: 350px) { 2884 .invest-mug { 2885 width: 26%; 2886 max-width: 100px; 2887 bottom: 4%; 2888 right: 4%; 2889 } 2890 .invest-mug-caption { 2891 font-size: 0.65em; 2892 margin-top: -7%; 2893 } 2894 } 2895 2896 /* Hacker Section - ghost-style minimal cards */ 2897 .hacker-section { 2898 grid-column: 1 / -1; 2899 display: flex; 2900 justify-content: center; 2901 gap: 1em; 2902 padding: 1em 0; 2903 margin-top: 0.5em; 2904 } 2905 2906 .hacker-card { 2907 flex: 0 1 auto; 2908 max-width: 420px; 2909 background: transparent; 2910 border: none; 2911 border-radius: 4px; 2912 padding: 1em 1.2em; 2913 font-family: var(--mono); 2914 font-size: 1.1em; 2915 color: rgba(255, 255, 255, 0.5); 2916 transition: border-color 0.2s, color 0.2s; 2917 } 2918 2919 .hacker-card:hover { 2920 border-color: rgba(205, 92, 155, 0.4); 2921 color: rgba(255, 255, 255, 0.7); 2922 } 2923 2924 .hacker-card a { 2925 color: rgba(205, 92, 155, 0.7); 2926 text-decoration: none; 2927 } 2928 2929 .hacker-card a:hover { 2930 color: rgb(205, 92, 155); 2931 text-decoration: none; 2932 } 2933 2934 .hacker-card-title { 2935 display: flex; 2936 flex-direction: column; 2937 align-items: center; 2938 gap: 0.2em; 2939 font-size: 0.9em; 2940 color: rgba(255, 255, 255, 0.4); 2941 margin-bottom: 0.5em; 2942 letter-spacing: 0.05em; 2943 text-align: center; 2944 text-decoration: none; 2945 } 2946 2947 .hacker-card-title:hover { 2948 color: rgba(255, 255, 255, 0.55); 2949 text-decoration: none !important; 2950 } 2951 2952 .at-line-1, .at-line-2 { 2953 display: block; 2954 white-space: nowrap; 2955 } 2956 2957 .at-gray { 2958 color: rgba(255, 255, 255, 0.4); 2959 } 2960 2961 .hacker-card-title:hover { 2962 color: rgba(255, 255, 255, 0.55); 2963 text-decoration: none; 2964 } 2965 2966 .hacker-card a { 2967 color: rgba(205, 92, 155, 0.8); 2968 text-decoration: none; 2969 } 2970 2971 .hacker-card-title a:hover { 2972 color: rgb(205, 92, 155); 2973 text-decoration: underline; 2974 } 2975 2976 .at-icon { 2977 width: 1.4em; 2978 height: 1.4em; 2979 vertical-align: -0.3em; 2980 margin-right: 0.3em; 2981 margin-left: 0.15em; 2982 } 2983 2984 .hacker-card-title a { 2985 font-size: 1.1em; 2986 } 2987 2988 .ac-name { 2989 color: inherit; 2990 } 2991 2992 .ac-dot { 2993 color: var(--pink); 2994 } 2995 2996 .at-blue { 2997 color: #0085FF; 2998 } 2999 3000 /* AT Protocol handle cycling animation */ 3001 .at-handle-prefix { 3002 display: inline-block; 3003 min-width: 3ch; 3004 text-align: right; 3005 } 3006 3007 .at-handle-prefix .at-char { 3008 display: inline-block; 3009 animation: atCharColor 1.8s steps(1) infinite; 3010 } 3011 3012 .at-handle-prefix .at-char-gray { 3013 display: inline-block; 3014 animation: atCharGray 1.8s steps(1) infinite; 3015 } 3016 3017 @keyframes atCharColor { 3018 0% { color: rgb(205, 92, 155); } 3019 16.6% { color: rgb(255, 120, 100); } 3020 33.3% { color: rgb(255, 200, 100); } 3021 50% { color: rgb(150, 230, 150); } 3022 66.6% { color: rgb(100, 180, 255); } 3023 83.3% { color: rgb(180, 130, 230); } 3024 100% { color: rgb(205, 92, 155); } 3025 } 3026 3027 @keyframes atCharGray { 3028 0% { color: rgba(255, 255, 255, 0.3); } 3029 16.6% { color: rgba(255, 255, 255, 0.45); } 3030 33.3% { color: rgba(255, 255, 255, 0.35); } 3031 50% { color: rgba(255, 255, 255, 0.5); } 3032 66.6% { color: rgba(255, 255, 255, 0.4); } 3033 83.3% { color: rgba(255, 255, 255, 0.3); } 3034 100% { color: rgba(255, 255, 255, 0.3); } 3035 } 3036 3037 .hacker-card-title code { 3038 background: rgba(205, 92, 155, 0.15); 3039 color: rgba(205, 92, 155, 0.8); 3040 padding: 0.1em 0.4em; 3041 border-radius: 2px; 3042 font-size: 1.1em; 3043 } 3044 3045 .hacker-card-body { 3046 line-height: 1.5; 3047 } 3048 3049 .hacker-card-body code { 3050 background: rgba(255, 255, 255, 0.05); 3051 padding: 0.1em 0.3em; 3052 border-radius: 2px; 3053 color: rgba(255, 255, 255, 0.6); 3054 } 3055 3056 .at-table { 3057 width: 100%; 3058 border-collapse: collapse; 3059 font-size: 0.9em; 3060 } 3061 3062 .at-table td { 3063 padding: 0.25em 0; 3064 vertical-align: top; 3065 } 3066 3067 .at-table td:first-child { 3068 color: rgba(255, 255, 255, 0.4); 3069 width: 5.5em; 3070 padding-right: 0.5em; 3071 } 3072 3073 .at-table td:last-child { 3074 color: rgba(255, 255, 255, 0.7); 3075 } 3076 3077 .at-table a { 3078 color: var(--pink); 3079 } 3080 3081 .at-table code { 3082 font-size: 0.95em; 3083 } 3084 3085 /* Apps Flip Card - Desktop/Mobile swivel */ 3086 .apps-flip-container { 3087 aspect-ratio: 1; 3088 position: relative; 3089 perspective: 1200px; 3090 border-radius: 8px; 3091 } 3092 3093 /* Glow removed - now only on tapes section */ 3094 3095 @keyframes glowRotate { 3096 to { --glow-angle: 360deg; } 3097 } 3098 3099 @property --glow-angle { 3100 syntax: '<angle>'; 3101 initial-value: 0deg; 3102 inherits: false; 3103 } 3104 3105 .apps-flip-face .module-caption { 3106 position: absolute; 3107 bottom: 3px; /* Above progress bar */ 3108 left: 0; 3109 right: 0; 3110 background: #000; 3111 color: rgba(255,255,255,0.5); 3112 padding: 0.8em; 3113 } 3114 3115 .apps-flip-card { 3116 position: absolute; 3117 inset: 0; 3118 transform-style: preserve-3d; 3119 transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1); 3120 } 3121 3122 .apps-flip-card.tilt-left { 3123 transform: rotateY(-8deg); 3124 } 3125 3126 .apps-flip-card.tilt-right { 3127 transform: rotateY(8deg); 3128 } 3129 3130 .apps-flip-card.flipped { 3131 transform: rotateY(180deg); 3132 } 3133 3134 .apps-flip-card.flipped.tilt-left { 3135 transform: rotateY(188deg); 3136 } 3137 3138 .apps-flip-card.flipped.tilt-right { 3139 transform: rotateY(172deg); 3140 } 3141 3142 .apps-flip-face { 3143 position: absolute; 3144 inset: 0; 3145 border-radius: 8px; 3146 overflow: hidden; 3147 display: flex; 3148 flex-direction: column; 3149 transition: opacity 0.1s ease, filter 0.1s ease; 3150 /* Safari mobile fix for border-radius */ 3151 -webkit-mask-image: -webkit-radial-gradient(white, black); 3152 isolation: isolate; 3153 } 3154 3155 .apps-flip-front { 3156 z-index: 1; 3157 opacity: 1; 3158 filter: none; 3159 } 3160 3161 .apps-flip-back { 3162 z-index: 3; 3163 transform: rotateY(180deg); 3164 opacity: 0.15; 3165 filter: blur(2px); 3166 pointer-events: none; 3167 } 3168 3169 .apps-flip-card.flipped .apps-flip-front { 3170 z-index: 3; 3171 opacity: 0.15; 3172 filter: blur(2px); 3173 pointer-events: none; 3174 } 3175 3176 .apps-flip-card.flipped .apps-flip-back { 3177 z-index: 1; 3178 opacity: 1; 3179 filter: none; 3180 pointer-events: auto; 3181 } 3182 3183 .apps-flip-trigger { 3184 cursor: pointer; 3185 user-select: none; 3186 } 3187 3188 .apps-flip-trigger:hover { 3189 opacity: 0.85; 3190 } 3191 3192 /* Desktop face - grayscale techy */ 3193 .desktop-face { 3194 background: linear-gradient(135deg, #1e1e1e 0%, #121212 100%); 3195 border: 1px solid #333; 3196 padding: 0.5em; 3197 padding-bottom: 3.5em; /* Space for absolute caption */ 3198 } 3199 3200 .desktop-face-header { 3201 display: flex; 3202 align-items: center; 3203 gap: 0.4em; 3204 padding-bottom: 0.4em; 3205 border-bottom: 1px solid rgba(255, 255, 255, 0.1); 3206 margin-bottom: 0.4em; 3207 overflow: hidden; 3208 cursor: pointer; 3209 } 3210 3211 .face-label { 3212 font-size: 0.85em; 3213 color: #aaa; 3214 font-family: monospace; 3215 text-transform: lowercase; 3216 letter-spacing: 0.05em; 3217 flex-shrink: 0; 3218 } 3219 3220 .desktop-face-header .version-badge { 3221 font-size: 0.65em; 3222 color: #6a6; 3223 background: rgba(100, 170, 100, 0.15); 3224 padding: 0.15em 0.5em; 3225 border-radius: 3px; 3226 margin-left: auto; 3227 font-family: monospace; 3228 } 3229 3230 .desktop-header-ticker { 3231 flex: 1; 3232 overflow: hidden; 3233 white-space: nowrap; 3234 margin-left: 0.5em; 3235 } 3236 3237 .desktop-header-ticker-content { 3238 display: inline-block; 3239 animation: tickerScroll 60s linear infinite; 3240 font-size: 0.6em; 3241 color: #666; 3242 } 3243 3244 .desktop-header-ticker-content span { 3245 margin-right: 2em; 3246 } 3247 3248 .desktop-face-header:hover .desktop-header-ticker-content { 3249 animation-play-state: paused; 3250 } 3251 3252 .desktop-platforms-grid { 3253 display: grid; 3254 grid-template-columns: repeat(3, 1fr); 3255 gap: 3px; 3256 flex: 1; 3257 } 3258 3259 .desktop-platform-btn { 3260 display: flex; 3261 flex-direction: column; 3262 align-items: center; 3263 justify-content: center; 3264 padding: 0.6em 0.3em; 3265 border: 1px solid rgba(255, 255, 255, 0.15); 3266 border-radius: 6px; 3267 text-decoration: none; 3268 transition: all 0.2s ease; 3269 } 3270 3271 .desktop-platform-btn.plat-mac { 3272 background: rgba(150, 150, 150, 0.25); 3273 } 3274 3275 .desktop-platform-btn.plat-win { 3276 background: rgba(0, 120, 212, 0.25); 3277 } 3278 3279 .desktop-platform-btn.plat-linux { 3280 background: rgba(245, 196, 0, 0.2); 3281 } 3282 3283 .desktop-platform-btn:hover { 3284 border-color: rgba(255, 255, 255, 0.4); 3285 transform: translateY(-2px); 3286 } 3287 3288 .desktop-platform-btn.plat-mac:hover { 3289 background: rgba(180, 180, 180, 0.35); 3290 } 3291 3292 .desktop-platform-btn.plat-win:hover { 3293 background: rgba(0, 120, 212, 0.4); 3294 } 3295 3296 .desktop-platform-btn.plat-linux:hover { 3297 background: rgba(245, 196, 0, 0.35); 3298 } 3299 3300 .desktop-platform-btn .plat-icon { 3301 width: 52px; 3302 height: 52px; 3303 margin-bottom: 0.2em; 3304 display: flex; 3305 align-items: center; 3306 justify-content: center; 3307 } 3308 3309 .desktop-platform-btn .plat-icon svg { 3310 width: 100%; 3311 height: 100%; 3312 fill: currentColor; 3313 } 3314 3315 .desktop-platform-btn .plat-name { 3316 font-size: 1.05em; 3317 color: #fff; 3318 font-weight: bold; 3319 } 3320 3321 .desktop-platform-btn .plat-arch { 3322 font-size: 0.7em; 3323 color: #aaa; 3324 font-family: var(--mono); 3325 } 3326 3327 .desktop-platform-btn:hover .plat-name { 3328 color: #fff; 3329 } 3330 3331 .desktop-changelog-ticker { 3332 background: rgba(0, 0, 0, 0.3); 3333 border-radius: 4px; 3334 padding: 0.3em 0.5em; 3335 margin-top: 0.4em; 3336 border: 1px solid rgba(255, 255, 255, 0.05); 3337 overflow: hidden; 3338 white-space: nowrap; 3339 } 3340 3341 .desktop-changelog-ticker-content { 3342 display: inline-block; 3343 animation: tickerScroll 60s linear infinite; 3344 font-size: 0.65em; 3345 color: var(--dim); 3346 } 3347 3348 .desktop-changelog-ticker-content span { 3349 margin-right: 2em; 3350 } 3351 3352 @keyframes tickerScroll { 3353 0% { transform: translateX(0%); } 3354 100% { transform: translateX(-50%); } 3355 } 3356 3357 .desktop-changelog-ticker:hover .desktop-changelog-ticker-content { 3358 animation-play-state: paused; 3359 } 3360 3361 .desktop-tools-row { 3362 display: flex; 3363 gap: 0.5em; 3364 margin-top: 0.5em; 3365 flex: 1; 3366 } 3367 3368 .desktop-vscode-link { 3369 display: flex; 3370 flex-direction: column; 3371 align-items: center; 3372 justify-content: center; 3373 gap: 0.3em; 3374 background: rgba(40, 40, 40, 0.95); 3375 border: 1px solid rgba(255, 255, 255, 0.15); 3376 border-radius: 4px; 3377 padding: 0.5em; 3378 text-decoration: none; 3379 transition: all 0.2s ease; 3380 flex: 1; 3381 } 3382 3383 .desktop-vscode-link:hover { 3384 border-color: rgba(255, 255, 255, 0.35); 3385 background: rgba(60, 60, 60, 0.95); 3386 transform: translateY(-1px); 3387 } 3388 3389 .desktop-vscode-link .vscode-icon-img { 3390 width: 56px; 3391 height: 56px; 3392 border-radius: 4px; 3393 } 3394 3395 .desktop-vscode-link .vscode-icon { 3396 font-size: 2.8em; 3397 } 3398 3399 .desktop-vscode-link .vscode-label { 3400 color: #ccc; 3401 font-weight: 600; 3402 font-size: 1em; 3403 } 3404 3405 .desktop-vscode-link .vscode-ver { 3406 color: #8c8; 3407 font-size: 0.75em; 3408 font-family: monospace; 3409 } 3410 3411 .desktop-ableton-link { 3412 display: flex; 3413 flex-direction: column; 3414 align-items: center; 3415 justify-content: center; 3416 gap: 0.3em; 3417 background: rgba(40, 40, 40, 0.95); 3418 border: 1px solid rgba(255, 255, 255, 0.15); 3419 border-radius: 4px; 3420 padding: 0.5em; 3421 text-decoration: none; 3422 transition: all 0.2s ease; 3423 flex: 1; 3424 } 3425 3426 .desktop-ableton-link:hover { 3427 border-color: rgba(255, 255, 255, 0.35); 3428 background: rgba(60, 60, 60, 0.95); 3429 transform: translateY(-1px); 3430 } 3431 3432 .desktop-ableton-link .ableton-icon { 3433 width: 56px; 3434 height: 56px; 3435 color: #fff; 3436 } 3437 3438 .desktop-ableton-link .ableton-label { 3439 color: #ccc; 3440 font-weight: 600; 3441 font-size: 1em; 3442 } 3443 3444 .desktop-ableton-link .ableton-tag { 3445 color: #aaa; 3446 font-size: 0.7em; 3447 font-family: monospace; 3448 background: rgba(255, 255, 255, 0.12); 3449 padding: 0.15em 0.4em; 3450 border-radius: 3px; 3451 } 3452 3453 /* Mobile face - grayscale techy */ 3454 .mobile-face { 3455 background: linear-gradient(135deg, #181818 0%, #101010 100%); 3456 border: 1px solid #2a2a2a; 3457 padding: 1em; 3458 padding-bottom: 3.5em; /* Space for absolute caption */ 3459 display: flex; 3460 flex-direction: column; 3461 } 3462 3463 .mobile-face-header { 3464 display: flex; 3465 align-items: center; 3466 gap: 0.5em; 3467 padding-bottom: 0.8em; 3468 border-bottom: 1px solid rgba(255, 255, 255, 0.08); 3469 margin-bottom: 0.8em; 3470 cursor: pointer; 3471 } 3472 3473 .mobile-face-content { 3474 display: flex; 3475 flex-direction: column; 3476 align-items: center; 3477 justify-content: center; 3478 flex: 1; 3479 gap: 0.8em; 3480 } 3481 3482 .mobile-app-icon { 3483 width: 90px; 3484 height: 90px; 3485 border-radius: 18px; 3486 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); 3487 } 3488 3489 .mobile-app-icon-fallback { 3490 width: 90px; 3491 height: 90px; 3492 border-radius: 18px; 3493 background: linear-gradient(135deg, #444 0%, #222 100%); 3494 display: flex; 3495 align-items: center; 3496 justify-content: center; 3497 font-size: 2.4em; 3498 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); 3499 } 3500 3501 .mobile-app-title { 3502 font-size: 1.15em; 3503 color: #aaa; 3504 font-weight: 500; 3505 text-align: center; 3506 display: flex; 3507 align-items: center; 3508 justify-content: center; 3509 line-height: 1; 3510 } 3511 3512 .app-bounce-dot { 3513 display: inline-flex; 3514 align-items: center; 3515 justify-content: center; 3516 color: var(--pink); 3517 font-weight: 900; 3518 animation: appDotBounce 1.5s ease-in-out infinite; 3519 margin: 0 -0.02em; 3520 position: relative; 3521 top: 0.02em; 3522 } 3523 3524 @keyframes appDotBounce { 3525 0%, 100% { transform: translateY(0) scale(1); } 3526 50% { transform: translateY(-3px) scale(1.2); } 3527 } 3528 3529 .mobile-store-link { 3530 display: inline-flex; 3531 align-items: center; 3532 gap: 0.5em; 3533 padding: 0.6em 1.2em; 3534 background: rgba(30, 30, 30, 0.9); 3535 border: 1px solid rgba(255, 255, 255, 0.15); 3536 border-radius: 8px; 3537 color: #ccc; 3538 text-decoration: none; 3539 font-size: 0.85em; 3540 transition: all 0.2s ease; 3541 } 3542 3543 .mobile-store-link:hover { 3544 background: rgba(50, 50, 50, 0.9); 3545 border-color: rgba(255, 255, 255, 0.3); 3546 transform: scale(1.02); 3547 color: #fff; 3548 } 3549 3550 .mobile-store-link .store-icon { 3551 font-size: 1.2em; 3552 } 3553 3554 /* VS Code Extension - Simple link block */ 3555 .vscode-section { 3556 background: linear-gradient(135deg, rgba(0, 122, 204, 0.15) 0%, rgba(0, 80, 150, 0.1) 100%); 3557 border: 1px solid rgba(0, 122, 204, 0.3); 3558 } 3559 3560 .vscode-section strong { 3561 color: #0078d4; 3562 } 3563 3564 .vscode-section .link-desc { 3565 background: transparent; 3566 } 3567 3568 .vscode-features-row { 3569 display: flex; 3570 flex-wrap: wrap; 3571 gap: 0.4em; 3572 margin-top: 0.6em; 3573 } 3574 3575 .vscode-feature-tag { 3576 font-size: 0.7em; 3577 padding: 0.2em 0.5em; 3578 background: rgba(0, 122, 204, 0.15); 3579 border: 1px solid rgba(0, 122, 204, 0.25); 3580 border-radius: 3px; 3581 color: var(--text); 3582 } 3583 3584 .vscode-install-link { 3585 display: inline-block; 3586 margin-top: 0.6em; 3587 padding: 0.4em 0.8em; 3588 background: #0078d4; 3589 border-radius: 4px; 3590 color: #fff; 3591 text-decoration: none; 3592 font-size: 0.8em; 3593 transition: background 0.2s ease; 3594 } 3595 3596 .vscode-install-link:hover { 3597 background: #1a8fe8; 3598 } 3599 3600 .feature-module { 3601 background: var(--box-bg); 3602 border: 1px solid var(--box-border); 3603 border-radius: 4px; 3604 padding: 1em 1.2em; 3605 } 3606 3607 .feature-module h3 { 3608 font-family: 'YWFTProcessing-Regular', sans-serif; 3609 font-size: 0.95em; 3610 margin: 0 0 0.7em 0; 3611 color: var(--cyan); 3612 } 3613 3614 .feature-module ul { 3615 list-style: none; 3616 margin: 0; 3617 padding: 0; 3618 display: flex; 3619 flex-direction: column; 3620 gap: 0.5em; 3621 } 3622 3623 .feature-module li { 3624 font-size: 0.85em; 3625 line-height: 1.4; 3626 padding-left: 0.5em; 3627 border-left: 2px solid var(--box-border); 3628 } 3629 3630 .feature-module li strong { 3631 color: var(--pink); 3632 } 3633 3634 .feature-module li a { 3635 color: inherit; 3636 text-decoration: none; 3637 } 3638 3639 .feature-module li a:hover { 3640 text-decoration: underline; 3641 } 3642 3643 .feature-module li a strong { 3644 color: var(--pink); 3645 } 3646 3647 .addr { 3648 font-size: 0.85em; 3649 background: var(--box-bg); 3650 padding: 0.3em 0.5em; 3651 border-radius: 2px; 3652 display: inline-flex; 3653 align-items: center; 3654 gap: 0.5em; 3655 margin-top: 0.2em; 3656 } 3657 3658 .addr button { 3659 font-family: var(--mono); 3660 font-size: 0.75em; 3661 padding: 0.2em 0.4em; 3662 background: transparent; 3663 border: 1px solid var(--dim); 3664 color: var(--dim); 3665 border-radius: 2px; 3666 cursor: pointer; 3667 } 3668 3669 .addr button:hover { 3670 border-color: var(--cyan); 3671 color: var(--cyan); 3672 } 3673 3674 /* Shop Section - clean white background like shop.aesthetic.computer */ 3675 .shop-section { 3676 background: #000; 3677 border: none; 3678 border-radius: 8px; 3679 padding: 0; 3680 color: #1a1a1a; 3681 overflow: hidden; 3682 display: flex; 3683 flex-direction: column; 3684 position: relative; 3685 aspect-ratio: 1; 3686 /* Safari mobile fix for border-radius */ 3687 -webkit-mask-image: -webkit-radial-gradient(white, black); 3688 isolation: isolate; 3689 z-index: 2; /* Above other modules so their shadows don't cover it */ 3690 } 3691 3692 .shop-header { 3693 position: absolute; 3694 top: 0; 3695 left: 0; 3696 right: 0; 3697 z-index: 3; 3698 background: transparent; 3699 padding: 0.8em 1em 2em 1em; 3700 text-align: left; 3701 } 3702 3703 .shop-title { 3704 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 3705 font-size: 1.3em; 3706 font-weight: 600; 3707 color: #fff; 3708 text-decoration: none; 3709 text-shadow: 0 1px 2px rgba(0,0,0,0.6); 3710 } 3711 3712 .shop-title:hover { 3713 color: var(--pink); 3714 } 3715 3716 .shop-section .module-caption { 3717 background: #000; 3718 color: rgba(255,255,255,0.5); 3719 font-family: var(--mono); 3720 z-index: 3; 3721 position: relative; 3722 } 3723 3724 .shop-products { 3725 position: relative; 3726 width: 100%; 3727 aspect-ratio: 1; 3728 overflow: hidden; 3729 } 3730 3731 /* Dual-layer container for crossfade transitions */ 3732 .shop-product-layer { 3733 position: absolute; 3734 top: 0; 3735 left: 0; 3736 width: 100%; 3737 height: 100%; 3738 transition: opacity 0.6s ease-in-out, transform 0.6s ease-in-out; 3739 } 3740 3741 .shop-product-layer.layer-back { 3742 z-index: 0; 3743 } 3744 3745 .shop-product-layer.layer-front { 3746 z-index: 1; 3747 } 3748 3749 .shop-product-layer.entering { 3750 opacity: 0; 3751 transform: scale(1.02); 3752 } 3753 3754 .shop-product-layer.visible { 3755 opacity: 1; 3756 transform: scale(1); 3757 } 3758 3759 .shop-product-layer.exiting { 3760 opacity: 0; 3761 transform: scale(0.98); 3762 } 3763 3764 .shop-product { 3765 display: block; 3766 width: 100%; 3767 height: 100%; 3768 text-decoration: none; 3769 color: #fff; 3770 position: relative; 3771 } 3772 3773 .shop-product-img-wrap { 3774 position: absolute; 3775 top: 0; 3776 left: 0; 3777 right: 0; 3778 bottom: 0; 3779 overflow: hidden; 3780 z-index: 0; /* Keep below overlay and price */ 3781 } 3782 3783 .shop-product-img-wrap::after { 3784 content: ""; 3785 position: absolute; 3786 inset: 0; 3787 background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.5) 100%); 3788 pointer-events: none; 3789 z-index: 1; 3790 } 3791 3792 .shop-product img { 3793 position: absolute; 3794 top: 0; 3795 left: 0; 3796 width: 100%; 3797 height: 100%; 3798 object-fit: cover; 3799 transform: scale(1.15); 3800 animation: kenBurns 15s ease-in-out infinite alternate; 3801 transition: opacity 0.4s ease; 3802 } 3803 3804 .shop-product img.shop-img-back { 3805 z-index: 0; 3806 } 3807 3808 .shop-product img.shop-img-front { 3809 z-index: 0; 3810 } 3811 3812 @keyframes kenBurns { 3813 0% { transform: scale(1.15) translate(0, 0); } 3814 25% { transform: scale(1.25) translate(-3%, -2%); } 3815 50% { transform: scale(1.2) translate(2%, -3%); } 3816 75% { transform: scale(1.3) translate(-2%, 2%); } 3817 100% { transform: scale(1.2) translate(2%, -1%); } 3818 } 3819 3820 .shop-product-overlay { 3821 position: absolute; 3822 left: 0; 3823 right: 0; 3824 bottom: 0; 3825 padding: 0.75em; 3826 padding-right: 5em; 3827 text-align: left; 3828 z-index: 2; /* Above the darkening ::after layer */ 3829 } 3830 3831 .shop-product-title-row { 3832 margin-bottom: 0.25em; 3833 } 3834 3835 .shop-product-title { 3836 font-family: 'YWFTProcessing-Regular', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 3837 font-size: 1.8em; 3838 font-weight: 500; 3839 color: #fff; 3840 text-shadow: 2px 2px 0 #000; 3841 line-height: 1.2; 3842 margin-right: 0.4em; 3843 } 3844 3845 .shop-product-vendor { 3846 font-family: var(--mono); 3847 font-size: 0.85em; 3848 color: #ff6b9d !important; 3849 text-shadow: 1px 1px 0 #000; 3850 display: block; 3851 margin-top: 0.2em; 3852 } 3853 3854 .shop-product-vendor a, 3855 .shop-product-vendor a:link, 3856 .shop-product-vendor a:visited, 3857 .shop-product-vendor a:active { 3858 color: #ff6b9d !important; 3859 text-decoration: none; 3860 } 3861 3862 .shop-product-vendor a:hover { 3863 text-decoration: underline; 3864 } 3865 3866 .shop-product-info { 3867 display: flex; 3868 flex-direction: row; 3869 align-items: center; 3870 justify-content: space-between; 3871 gap: 1em; 3872 } 3873 3874 .shop-product-desc { 3875 font-family: var(--mono); 3876 font-size: 0.8em; 3877 color: rgba(255, 255, 255, 0.9); 3878 line-height: 1.5; 3879 text-shadow: 1px 1px 0 #000; 3880 max-height: 3.6em; 3881 overflow: hidden; 3882 -webkit-mask-image: linear-gradient(180deg, #000 70%, transparent 100%); 3883 mask-image: linear-gradient(180deg, #000 70%, transparent 100%); 3884 margin-top: 0.3em; 3885 } 3886 3887 .shop-product-desc-inner { 3888 animation: descScroll 12s ease-in-out infinite; 3889 animation-delay: 2s; 3890 } 3891 3892 @keyframes descScroll { 3893 0%, 20% { transform: translateY(0); } 3894 80%, 100% { transform: translateY(calc(-100% + 3.6em)); } 3895 } 3896 3897 .shop-product-price { 3898 position: absolute; 3899 top: 0.3em; 3900 right: 0.6em; 3901 z-index: 100; 3902 font-family: 'YWFTProcessing-Regular', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 3903 font-size: 2.4em; 3904 color: #00ff66; 3905 font-weight: 500; 3906 text-shadow: 3px 3px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 0 0 12px rgba(0, 0, 0, 0.9); 3907 white-space: nowrap; 3908 } 3909 3910 .shop-product-sold { 3911 position: absolute; 3912 top: 0.5em; 3913 right: 0.5em; 3914 z-index: 10; 3915 background: #d32f2f; 3916 color: #fff; 3917 font-size: 1em; 3918 font-weight: 700; 3919 text-transform: uppercase; 3920 padding: 0.25em 0.6em; 3921 border-radius: 3px; 3922 white-space: nowrap; 3923 } 3924 3925 @keyframes shopFadeIn { 3926 to { opacity: 1; } 3927 } 3928 3929 /* Shop auto-advance progress bar - inside panel */ 3930 .shop-auto-progress { 3931 position: absolute; 3932 bottom: 0; 3933 left: 0; 3934 right: 0; 3935 height: 3px; 3936 background: rgba(255, 255, 255, 0.08); 3937 z-index: 15; 3938 overflow: hidden; 3939 } 3940 3941 .shop-auto-progress-bar { 3942 height: 100%; 3943 width: 0%; 3944 background: rgba(255, 255, 255, 0.4); 3945 transition: width 0.05s linear; 3946 } 3947 3948 /* AT Proto Section - decentralized identity */ 3949 .at-section { 3950 background: rgb(20, 18, 24); 3951 border-radius: 8px; 3952 overflow: hidden; 3953 display: flex; 3954 flex-direction: column; 3955 position: relative; 3956 aspect-ratio: 1; 3957 box-shadow: 3958 0 0 0 1px rgb(205, 92, 155), 3959 0 4px 20px rgba(205, 92, 155, 0.2); 3960 -webkit-mask-image: -webkit-radial-gradient(white, black); 3961 isolation: isolate; 3962 font-family: var(--mono); 3963 } 3964 3965 .at-header { 3966 padding: 0.5em 0.7em; 3967 text-decoration: none; 3968 background: transparent; 3969 display: flex; 3970 align-items: center; 3971 justify-content: space-between; 3972 } 3973 3974 .at-header strong { 3975 font-family: var(--mono); 3976 font-size: 0.85em; 3977 color: rgb(205, 92, 155); 3978 font-weight: 600; 3979 } 3980 3981 .at-header:hover strong { 3982 color: #fff; 3983 } 3984 3985 .at-record-display { 3986 flex: 1; 3987 position: relative; 3988 overflow: hidden; 3989 } 3990 3991 .at-record { 3992 position: absolute; 3993 inset: 0; 3994 display: flex; 3995 flex-direction: column; 3996 opacity: 0; 3997 transition: opacity 0.4s ease; 3998 } 3999 4000 .at-record.active { 4001 opacity: 1; 4002 } 4003 4004 /* Center content - letterboxed painting or large mood */ 4005 .at-record-center { 4006 flex: 1; 4007 display: flex; 4008 align-items: center; 4009 justify-content: center; 4010 padding: 0.3em 0.8em; 4011 min-height: 0; 4012 overflow: hidden; 4013 } 4014 4015 /* Handle and type below content */ 4016 .at-record-meta { 4017 display: flex; 4018 flex-direction: column; 4019 align-items: center; 4020 padding: 0 0.6em 0.1em; 4021 text-align: center; 4022 margin-top: -0.3em; 4023 } 4024 4025 .at-record-handle { 4026 color: rgb(205, 92, 155); 4027 font-weight: 700; 4028 font-size: 0.85em; 4029 letter-spacing: -0.01em; 4030 } 4031 4032 .at-record-type { 4033 color: rgba(255, 255, 255, 0.6); 4034 text-transform: uppercase; 4035 font-size: 0.55em; 4036 letter-spacing: 0.1em; 4037 margin-top: 0.05em; 4038 } 4039 4040 /* Painting: letterboxed with Ken Burns animation */ 4041 .at-record-thumb-wrap { 4042 position: relative; 4043 width: 80%; 4044 max-height: 90%; 4045 aspect-ratio: 16/10; 4046 border-radius: 4px; 4047 overflow: hidden; 4048 box-shadow: 4049 0 8px 30px rgba(0, 0, 0, 0.6), 4050 0 2px 8px rgba(0, 0, 0, 0.4), 4051 inset 0 0 0 1px rgba(255, 255, 255, 0.08); 4052 background: #000; 4053 } 4054 4055 .at-record-thumb { 4056 width: 100%; 4057 height: 100%; 4058 object-fit: cover; 4059 animation: atKenBurns 12s ease-in-out infinite alternate; 4060 } 4061 4062 @keyframes atKenBurns { 4063 0% { transform: scale(1) translate(0, 0); } 4064 100% { transform: scale(1.12) translate(-2%, -1%); } 4065 } 4066 4067 .at-record-mood { 4068 font-size: 1.4em; 4069 text-align: center; 4070 color: rgba(255, 255, 255, 0.95); 4071 line-height: 1.35; 4072 padding: 0.3em 0.5em; 4073 font-family: var(--sans); 4074 max-height: 100%; 4075 overflow: hidden; 4076 font-weight: 500; 4077 } 4078 4079 /* Bottom: AT data proof */ 4080 .at-record-bottom { 4081 display: flex; 4082 flex-direction: column; 4083 gap: 0.15em; 4084 padding: 0.3em 0.6em 0.4em; 4085 font-size: 0.5em; 4086 text-align: center; 4087 opacity: 0.7; 4088 } 4089 4090 .at-record-uri { 4091 color: rgba(205, 92, 155, 0.8); 4092 white-space: nowrap; 4093 overflow: hidden; 4094 text-overflow: ellipsis; 4095 font-family: var(--mono); 4096 } 4097 4098 .at-record-collection { 4099 color: rgba(255, 255, 255, 0.5); 4100 font-family: var(--mono); 4101 letter-spacing: 0.02em; 4102 } 4103 4104 /* Progress bar - aligned with caption top */ 4105 .at-progress { 4106 position: absolute; 4107 bottom: 2.35em; 4108 left: 0; 4109 right: 0; 4110 height: 2px; 4111 background: rgba(205, 92, 155, 0.2); 4112 z-index: 3; 4113 } 4114 4115 .at-progress-bar { 4116 height: 100%; 4117 background: rgb(205, 92, 155); 4118 width: 0%; 4119 transition: width 0.05s linear; 4120 } 4121 4122 .at-section .module-caption { 4123 position: absolute; 4124 bottom: 0; 4125 left: 0; 4126 right: 0; 4127 padding: 0.8em 1em; 4128 font-size: 0.75em; 4129 color: rgba(205, 92, 155, 0.8); 4130 background: rgb(40, 35, 50); 4131 z-index: 2; 4132 } 4133 4134 /* TV Section - letterbox video feed */ 4135 .tv-section { 4136 background: #000; 4137 border-radius: 8px; 4138 overflow: hidden; 4139 display: flex; 4140 flex-direction: column; 4141 position: relative; 4142 aspect-ratio: 1; 4143 /* VHS chromatic aberration shadow - subtle */ 4144 box-shadow: 4145 -2px 0 0 #ff0080, 4146 2px 0 0 #00e5ff, 4147 -3px 2px 0 rgba(255, 0, 128, 0.4), 4148 3px 2px 0 rgba(0, 229, 255, 0.4), 4149 0 6px 20px rgba(0, 0, 0, 0.6); 4150 /* Safari mobile fix for border-radius */ 4151 -webkit-mask-image: -webkit-radial-gradient(white, black); 4152 isolation: isolate; 4153 } 4154 4155 .tv-header { 4156 position: absolute; 4157 top: 0; 4158 left: 0; 4159 right: 0; 4160 z-index: 10; 4161 padding: 0.6em 0.8em; 4162 text-decoration: none; 4163 cursor: pointer; 4164 background: transparent; 4165 display: flex; 4166 flex-direction: column; 4167 gap: 2px; 4168 } 4169 4170 .tv-header:hover { 4171 text-decoration: none; 4172 } 4173 4174 .tv-header-row { 4175 display: flex; 4176 flex-direction: column; 4177 align-items: flex-start; 4178 gap: 0.08em; 4179 } 4180 4181 /* Individual code animations - current slides in from bottom, others shift up */ 4182 @keyframes tvCodeEnter { 4183 from { 4184 opacity: 0; 4185 transform: translateY(1.2em); 4186 } 4187 to { 4188 opacity: 1; 4189 transform: translateY(0); 4190 } 4191 } 4192 4193 @keyframes tvCodeShiftUp { 4194 from { 4195 transform: translateY(1.2em); 4196 } 4197 to { 4198 transform: translateY(0); 4199 } 4200 } 4201 4202 /* Make upcoming codes look tappable */ 4203 .tv-header .tv-upcoming-code { 4204 cursor: pointer; 4205 text-decoration: none !important; 4206 transition: color 0.15s, transform 0.15s; 4207 padding: 0.05em 0; 4208 display: block; 4209 animation: tvCodeShiftUp 0.35s cubic-bezier(0.22, 1, 0.36, 1); 4210 } 4211 4212 .tv-header .tv-upcoming-code:hover, 4213 .tv-header .tv-upcoming-code:active { 4214 color: rgba(255,255,255,0.85); 4215 transform: translateX(4px); 4216 } 4217 4218 .tv-header .tv-upcoming-code:active { 4219 transform: translateX(4px) scale(0.95); 4220 } 4221 4222 /* Decreasing sizes for upcoming codes */ 4223 .tv-header .tv-upcoming-code:nth-child(1) { 4224 font-size: 0.9em; 4225 opacity: 0.55; 4226 } 4227 .tv-header .tv-upcoming-code:nth-child(2) { 4228 font-size: 0.75em; 4229 opacity: 0.4; 4230 } 4231 .tv-header .tv-upcoming-code:nth-child(3) { 4232 font-size: 0.6em; 4233 opacity: 0.3; 4234 } 4235 4236 .tv-header .tv-code { 4237 font-family: 'YWFTProcessing-Regular', var(--mono), sans-serif; 4238 font-size: 1.4em; 4239 color: #fff; 4240 font-weight: 500; 4241 letter-spacing: 0.02em; 4242 text-shadow: 2px 2px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 0 0 8px rgba(0, 0, 0, 0.9); 4243 animation: tvCodeEnter 0.35s cubic-bezier(0.22, 1, 0.36, 1); 4244 } 4245 4246 .tv-header .tv-upcoming { 4247 font-family: 'YWFTProcessing-Regular', var(--mono), sans-serif; 4248 font-size: 1.1em; 4249 color: rgba(255, 255, 255, 0.5); 4250 font-weight: 400; 4251 letter-spacing: 0.02em; 4252 display: flex; 4253 flex-direction: column; 4254 gap: 0.05em; 4255 text-shadow: 1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 0 0 6px rgba(0, 0, 0, 0.8); 4256 } 4257 4258 .tv-header .tv-timestamp { 4259 color: rgba(255,255,255,0.6); 4260 font-family: var(--mono); 4261 font-size: 0.7em; 4262 font-weight: 400; 4263 } 4264 4265 .tv-header:hover .tv-code { 4266 color: var(--pink); 4267 } 4268 4269 /* Mute button */ 4270 .tv-mute-btn { 4271 position: absolute; 4272 top: 8px; 4273 right: 8px; 4274 width: 28px; 4275 height: 28px; 4276 border-radius: 4px; 4277 background: transparent; 4278 border: none; 4279 cursor: pointer; 4280 z-index: 20; 4281 display: flex; 4282 align-items: center; 4283 justify-content: center; 4284 transition: transform 0.2s; 4285 } 4286 4287 .tv-mute-btn:hover { 4288 transform: scale(1.1); 4289 } 4290 4291 .tv-mute-btn svg { 4292 width: 22px; 4293 height: 22px; 4294 fill: rgba(255,255,255,0.9); 4295 filter: drop-shadow(1px 1px 0 #000) drop-shadow(-1px -1px 0 #000) drop-shadow(1px -1px 0 #000) drop-shadow(-1px 1px 0 #000); 4296 } 4297 4298 .tv-mute-btn.muted svg .sound-wave { 4299 display: none; 4300 } 4301 4302 .tv-section .module-caption { 4303 position: absolute; 4304 bottom: 0; 4305 left: 0; 4306 right: 0; 4307 z-index: 5; 4308 background: rgb(40, 40, 50); 4309 color: rgba(255, 255, 255, 0.7); 4310 padding: 0.8em 1em; 4311 font-size: 0.75em; 4312 } 4313 4314 .tv-player { 4315 position: absolute; 4316 inset: 0; 4317 bottom: 2.4em; /* Leave space for caption */ 4318 background: #000; 4319 display: flex; 4320 align-items: center; 4321 justify-content: center; 4322 } 4323 4324 .tv-player video { 4325 max-width: 100%; 4326 max-height: 100%; 4327 object-fit: contain; 4328 background: #000; 4329 } 4330 4331 .tv-no-signal { 4332 color: rgba(255,255,255,0.3); 4333 font-size: 0.8em; 4334 font-family: var(--mono); 4335 } 4336 4337 .tv-progress { 4338 position: absolute; 4339 bottom: 0; /* At bottom of tv-player, which ends at top of caption */ 4340 left: 0; 4341 right: 0; 4342 height: 32px; 4343 background: linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.4) 60%, transparent 100%); 4344 z-index: 12; 4345 display: flex; 4346 flex-direction: column; 4347 justify-content: flex-end; 4348 } 4349 4350 .tv-progress-bar { 4351 position: absolute; 4352 bottom: 0; 4353 left: 0; 4354 height: 3px; 4355 background: #e53935; 4356 width: 0%; 4357 } 4358 4359 .tv-progress { 4360 cursor: pointer; 4361 } 4362 4363 .tv-progress:hover .tv-progress-bar { 4364 height: 5px; 4365 } 4366 4367 .tv-timer { 4368 position: absolute; 4369 bottom: 8px; 4370 right: 10px; 4371 font-family: var(--mono); 4372 font-size: 0.85em; 4373 color: rgba(255,255,255,0.9); 4374 text-shadow: 0 1px 3px rgba(0,0,0,0.7); 4375 z-index: 13; 4376 cursor: pointer; 4377 } 4378 4379 .tv-timer:hover { 4380 color: var(--pink); 4381 } 4382 4383 .tv-play-indicator { 4384 position: absolute; 4385 bottom: 14px; 4386 left: 10px; 4387 width: 24px; 4388 height: 24px; 4389 border-radius: 4px; 4390 background: transparent; 4391 display: flex; 4392 align-items: center; 4393 justify-content: center; 4394 z-index: 15; 4395 pointer-events: none; 4396 opacity: 0.9; 4397 transition: opacity 0.2s ease; 4398 } 4399 4400 .tv-play-indicator::after { 4401 /* Pause icon (two bars) - shown while playing */ 4402 content: ''; 4403 display: block; 4404 width: 12px; 4405 height: 14px; 4406 background: linear-gradient(to right, 4407 rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.95) 35%, 4408 transparent 35%, transparent 65%, 4409 rgba(255,255,255,0.95) 65%, rgba(255,255,255,0.95) 100%); 4410 filter: drop-shadow(0 1px 2px rgba(0,0,0,0.4)); 4411 } 4412 4413 .tv-play-indicator.paused { 4414 opacity: 1; 4415 } 4416 4417 .tv-play-indicator.paused::after { 4418 /* Play icon (triangle) - shown when paused */ 4419 background: none; 4420 width: 0; 4421 height: 0; 4422 border-style: solid; 4423 border-width: 6px 0 6px 10px; 4424 border-color: transparent transparent transparent rgba(255,255,255,0.95); 4425 margin-left: 2px; 4426 filter: drop-shadow(0 1px 2px rgba(0,0,0,0.4)); 4427 } 4428 4429 .tv-slide:hover .tv-play-indicator { 4430 opacity: 1; 4431 } 4432 4433 .tv-slide:hover .tv-play-indicator.paused { 4434 opacity: 1; 4435 } 4436 4437 /* Tape slide animation - vertical swipe like TikTok */ 4438 .tv-slide { 4439 position: absolute; 4440 inset: 0; 4441 display: flex; 4442 align-items: center; 4443 justify-content: center; 4444 overflow: hidden; 4445 background: #000; 4446 /* iOS Safari fixes */ 4447 -webkit-transform: translateZ(0); 4448 transform: translateZ(0); 4449 -webkit-backface-visibility: hidden; 4450 backface-visibility: hidden; 4451 } 4452 4453 /* Big centered play/pause button - appears on tap */ 4454 .tv-big-play { 4455 position: absolute; 4456 top: 50%; 4457 left: 50%; 4458 transform: translate(-50%, -50%) scale(0.8); 4459 width: 80px; 4460 height: 80px; 4461 border-radius: 50%; 4462 background: rgba(0, 0, 0, 0.6); 4463 display: flex; 4464 align-items: center; 4465 justify-content: center; 4466 z-index: 25; 4467 pointer-events: none; 4468 opacity: 0; 4469 transition: opacity 0.2s ease, transform 0.2s ease; 4470 -webkit-backdrop-filter: blur(8px); 4471 backdrop-filter: blur(8px); 4472 } 4473 4474 .tv-big-play.visible { 4475 opacity: 1; 4476 transform: translate(-50%, -50%) scale(1); 4477 } 4478 4479 .tv-big-play::after { 4480 content: ''; 4481 display: block; 4482 width: 0; 4483 height: 0; 4484 border-style: solid; 4485 border-width: 15px 0 15px 26px; 4486 border-color: transparent transparent transparent rgba(255, 255, 255, 0.95); 4487 margin-left: 6px; 4488 } 4489 4490 .tv-big-play.paused::after { 4491 /* Pause icon - two bars */ 4492 border: none; 4493 width: 26px; 4494 height: 30px; 4495 background: linear-gradient(to right, 4496 rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.95) 35%, 4497 transparent 35%, transparent 65%, 4498 rgba(255,255,255,0.95) 65%, rgba(255,255,255,0.95) 100%); 4499 margin-left: 0; 4500 } 4501 4502 .tv-slide video { 4503 max-width: 100%; 4504 max-height: 100%; 4505 object-fit: contain; 4506 background: #000; 4507 /* VHS color bleeding effect */ 4508 filter: saturate(1.1) contrast(1.05); 4509 position: relative; 4510 z-index: 2; 4511 } 4512 4513 /* Blurred background glow video - fills letterbox with soft color */ 4514 .tv-bg-glow { 4515 position: absolute; 4516 top: 50%; 4517 left: 50%; 4518 transform: translate(-50%, -50%) scale(1.8); 4519 width: 100%; 4520 height: 100%; 4521 object-fit: cover; 4522 filter: blur(30px) saturate(1.8) brightness(0.8); 4523 z-index: 0; 4524 pointer-events: none; 4525 opacity: 1; 4526 } 4527 4528 /* TV section wrapper for backdrop glow effect */ 4529 .tv-section-wrap { 4530 position: relative; 4531 isolation: isolate; 4532 padding-left: 8px; /* Allow chromatic effect to bleed left */ 4533 margin-left: -8px; 4534 } 4535 4536 /* Colorful video blur backdrop behind the panel - like a glowing drop shadow */ 4537 .tv-backdrop-glow { 4538 position: absolute; 4539 top: 10px; 4540 left: 8px; 4541 right: 0; 4542 bottom: -10px; 4543 border-radius: 12px; 4544 overflow: hidden; 4545 filter: blur(20px) saturate(1.3) brightness(0.6); 4546 opacity: 0.6; 4547 z-index: -1; 4548 pointer-events: none; 4549 } 4550 4551 .tv-backdrop-glow video { 4552 position: absolute; 4553 top: 50%; 4554 left: 50%; 4555 transform: translate(-50%, -50%) scale(2); 4556 width: 100%; 4557 height: 100%; 4558 object-fit: cover; 4559 } 4560 4561 /* VHS scanlines overlay - scrolling */ 4562 .tv-slide::before { 4563 content: ''; 4564 position: absolute; 4565 inset: -100% 0; 4566 height: 300%; 4567 background: repeating-linear-gradient( 4568 0deg, 4569 transparent 0px, 4570 transparent 2px, 4571 rgba(0, 0, 0, 0.2) 2px, 4572 rgba(0, 0, 0, 0.2) 4px 4573 ); 4574 pointer-events: none; 4575 z-index: 5; 4576 animation: scanlineScroll 8s linear infinite; 4577 } 4578 4579 @keyframes scanlineScroll { 4580 0% { transform: translateY(0); } 4581 100% { transform: translateY(33.33%); } 4582 } 4583 4584 /* VHS noise overlay */ 4585 .tv-slide::after { 4586 content: ''; 4587 position: absolute; 4588 inset: 0; 4589 background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); 4590 opacity: 0.04; 4591 pointer-events: none; 4592 z-index: 6; 4593 mix-blend-mode: overlay; 4594 } 4595 4596 .tv-slide.entering { 4597 animation: tvSlideIn 0.4s ease-out forwards; 4598 z-index: 3; 4599 } 4600 4601 .tv-slide.exiting { 4602 animation: tvSlideOut 0.4s ease-out forwards; 4603 z-index: 2; 4604 } 4605 4606 @keyframes tvSlideIn { 4607 from { 4608 transform: translateY(100%); 4609 } 4610 to { 4611 transform: translateY(0); 4612 } 4613 } 4614 4615 @keyframes tvSlideOut { 4616 from { 4617 transform: translateY(0); 4618 opacity: 1; 4619 } 4620 to { 4621 transform: translateY(-100%); 4622 opacity: 0; 4623 } 4624 } 4625 4626 /* Flip Card System - Desktop App Style (ghost overlay approach) */ 4627 .chat-flip-container { 4628 aspect-ratio: 1; 4629 position: relative; 4630 perspective: 1200px; 4631 border-radius: 8px; 4632 } 4633 4634 /* Glow removed - now only on tapes section */ 4635 4636 /* The flipping card - this rotates in 3D */ 4637 .chat-flip-card { 4638 position: absolute; 4639 inset: 0; 4640 transform-style: preserve-3d; 4641 transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1); 4642 } 4643 4644 /* Hover tilt effect - subtle swivel preview */ 4645 .chat-flip-card.tilt-left { 4646 transform: rotateY(-8deg); 4647 } 4648 4649 .chat-flip-card.tilt-right { 4650 transform: rotateY(8deg); 4651 } 4652 4653 .chat-flip-card.flipped { 4654 transform: rotateY(180deg); 4655 } 4656 4657 /* Tilt when flipped (reversed direction since we're looking at the back) */ 4658 .chat-flip-card.flipped.tilt-left { 4659 transform: rotateY(188deg); 4660 } 4661 4662 .chat-flip-card.flipped.tilt-right { 4663 transform: rotateY(172deg); 4664 } 4665 4666 /* Both faces positioned together */ 4667 .chat-flip-face { 4668 position: absolute; 4669 inset: 0; 4670 border-radius: 8px; 4671 overflow: hidden; 4672 display: flex; 4673 flex-direction: column; 4674 transition: opacity 0.1s ease, filter 0.1s ease; 4675 /* Safari mobile fix for border-radius */ 4676 -webkit-mask-image: -webkit-radial-gradient(white, black); 4677 isolation: isolate; 4678 } 4679 4680 /* Front face - laer-klokken - active when NOT flipped */ 4681 .chat-flip-front { 4682 z-index: 1; 4683 opacity: 1; 4684 filter: none; 4685 } 4686 4687 /* Back face - chat-system - ghost ON TOP when NOT flipped */ 4688 .chat-flip-back { 4689 z-index: 3; 4690 transform: rotateY(180deg); /* Shows mirrored when viewed from front */ 4691 opacity: 0.15; 4692 filter: blur(2px); 4693 pointer-events: none; 4694 } 4695 4696 /* When FLIPPED: front becomes ghost, back becomes active */ 4697 .chat-flip-card.flipped .chat-flip-front { 4698 z-index: 3; 4699 opacity: 0.15; 4700 filter: blur(2px); 4701 pointer-events: none; 4702 } 4703 4704 .chat-flip-card.flipped .chat-flip-back { 4705 z-index: 1; 4706 opacity: 1; 4707 filter: none; 4708 pointer-events: auto; 4709 } 4710 4711 /* Clickable areas for flip */ 4712 .chat-flip-trigger { 4713 cursor: pointer; 4714 user-select: none; 4715 } 4716 4717 .chat-flip-trigger:hover { 4718 opacity: 0.85; 4719 } 4720 4721 /* Embedded progress bar inside flip faces */ 4722 .flip-face-progress { 4723 position: absolute; 4724 bottom: 0; 4725 left: 0; 4726 right: 0; 4727 height: 3px; 4728 background: rgba(0, 0, 0, 0.3); 4729 overflow: hidden; 4730 z-index: 10; 4731 } 4732 4733 .flip-face-progress-bar { 4734 height: 100%; 4735 width: 0%; 4736 background: var(--gold); 4737 transition: width 0.1s linear; 4738 } 4739 4740 /* Chat flip - gold for laer-klokken, purple for chat */ 4741 .chat-preview .flip-face-progress-bar { 4742 background: var(--gold); 4743 } 4744 4745 .chat-preview-system .flip-face-progress-bar { 4746 background: #8844ff; 4747 } 4748 4749 /* Apps flip - silvery for both faces */ 4750 .desktop-face .flip-face-progress-bar, 4751 .mobile-face .flip-face-progress-bar { 4752 background: linear-gradient(90deg, #666 0%, #888 50%, #aaa 100%); 4753 } 4754 4755 /* Chat Rolodex - laer-klokken warm gold theme */ 4756 .chat-preview { 4757 background: linear-gradient(135deg, #2a2010 0%, #1a1508 100%); 4758 border: 1px solid #3d3520; 4759 border-radius: 8px; 4760 overflow: hidden; 4761 aspect-ratio: 1; 4762 display: flex; 4763 flex-direction: column; 4764 position: relative; 4765 scrollbar-width: none; 4766 -ms-overflow-style: none; 4767 /* Safari mobile fix for border-radius */ 4768 -webkit-mask-image: -webkit-radial-gradient(white, black); 4769 isolation: isolate; 4770 } 4771 .chat-preview::-webkit-scrollbar { display: none; } 4772 4773 .chat-preview-header { 4774 position: sticky; 4775 top: 0; 4776 z-index: 1; 4777 padding: 0.8em 1em; 4778 text-decoration: none; 4779 background: linear-gradient(135deg, #2a2010 0%, #1a1508 100%); 4780 } 4781 4782 .chat-preview-header strong { 4783 color: var(--gold); 4784 font-family: 'YWFTProcessing-Regular', sans-serif; 4785 font-size: 1.05em; 4786 } 4787 4788 .chat-preview-header:hover strong { 4789 color: #ffe066; 4790 } 4791 4792 .chat-preview .module-caption { 4793 background: linear-gradient(135deg, #2a2010 0%, #1a1508 100%); 4794 color: #a08050; 4795 } 4796 4797 .chat-messages { 4798 flex: 1; 4799 overflow: hidden; 4800 background: #111; 4801 pointer-events: none; 4802 scrollbar-width: none; 4803 -ms-overflow-style: none; 4804 } 4805 4806 .chat-messages::-webkit-scrollbar { 4807 display: none; 4808 } 4809 4810 .chat-msg { 4811 padding: 0.5em 1em; 4812 border-bottom: 1px solid rgba(255,255,255,0.05); 4813 animation: chatFadeIn 0.3s ease; 4814 } 4815 4816 .chat-msg:last-child { 4817 border-bottom: none; 4818 } 4819 4820 @keyframes chatFadeIn { 4821 from { opacity: 0; transform: translateY(-5px); } 4822 to { opacity: 1; transform: translateY(0); } 4823 } 4824 4825 .chat-msg-header { 4826 display: flex; 4827 justify-content: space-between; 4828 align-items: baseline; 4829 margin-bottom: 0.2em; 4830 } 4831 4832 .chat-handle { 4833 font-family: var(--mono); 4834 font-size: 0.75em; 4835 color: var(--cyan); 4836 } 4837 4838 .chat-handle:hover { 4839 color: var(--pink); 4840 } 4841 4842 .chat-time { 4843 font-family: var(--mono); 4844 font-size: 0.6em; 4845 color: var(--dim); 4846 } 4847 4848 .chat-text { 4849 font-size: 0.8em; 4850 color: var(--text); 4851 line-height: 1.4; 4852 overflow: hidden; 4853 text-overflow: ellipsis; 4854 display: -webkit-box; 4855 -webkit-line-clamp: 2; 4856 -webkit-box-orient: vertical; 4857 } 4858 4859 .chat-text .chat-link-url { 4860 color: var(--cyan); 4861 text-decoration: none; 4862 } 4863 4864 .chat-text .chat-link-url:hover { 4865 text-decoration: underline; 4866 } 4867 4868 .chat-text .chat-link-handle { 4869 color: var(--pink); 4870 text-decoration: none; 4871 } 4872 4873 .chat-text .chat-link-handle:hover { 4874 text-decoration: underline; 4875 } 4876 4877 .chat-text .chat-link-prompt { 4878 color: inherit; 4879 text-decoration: none; 4880 font-family: var(--mono); 4881 } 4882 4883 .chat-text .chat-link-prompt:hover { 4884 text-decoration: none; 4885 opacity: 0.8; 4886 } 4887 4888 .chat-text .chat-link-painting { 4889 color: #95E1D3; 4890 text-decoration: none; 4891 } 4892 4893 .chat-text .chat-link-painting:hover { 4894 text-decoration: underline; 4895 } 4896 4897 /* Painting thumbnail previews in chat */ 4898 .chat-painting-thumbs { 4899 display: flex; 4900 flex-wrap: wrap; 4901 gap: 4px; 4902 margin-top: 6px; 4903 } 4904 4905 .chat-painting-thumb { 4906 width: 48px; 4907 height: 48px; 4908 border-radius: 4px; 4909 overflow: hidden; 4910 background: #1a1a1a; 4911 position: relative; 4912 } 4913 4914 .chat-painting-thumb a { 4915 display: block; 4916 width: 100%; 4917 height: 100%; 4918 } 4919 4920 .chat-painting-thumb img { 4921 width: 100%; 4922 height: 100%; 4923 object-fit: cover; 4924 opacity: 0; 4925 transition: opacity 0.3s ease; 4926 } 4927 4928 .chat-painting-thumb img.loaded { 4929 opacity: 1; 4930 } 4931 4932 .chat-painting-thumb:hover { 4933 outline: 2px solid var(--cyan); 4934 } 4935 4936 .chat-painting-thumb .thumb-code { 4937 position: absolute; 4938 bottom: 0; 4939 left: 0; 4940 right: 0; 4941 font-family: var(--mono); 4942 font-size: 0.5em; 4943 color: #fff; 4944 background: rgba(0,0,0,0.7); 4945 padding: 1px 3px; 4946 text-align: center; 4947 } 4948 4949 /* Chat System (back face) - purple theme (lighter) */ 4950 .chat-preview-system { 4951 background: linear-gradient(135deg, #252540 0%, #1a1230 100%); 4952 border: 1px solid rgba(160, 100, 255, 0.35); 4953 } 4954 4955 .chat-system-header { 4956 position: sticky; 4957 top: 0; 4958 z-index: 1; 4959 padding: 0.8em 1em; 4960 text-decoration: none; 4961 background: linear-gradient(135deg, #252540 0%, #1a1230 100%); 4962 } 4963 4964 .chat-system-header strong { 4965 color: rgba(180, 140, 255, 0.95); 4966 font-family: 'YWFTProcessing-Regular', sans-serif; 4967 font-size: 1.05em; 4968 } 4969 4970 .chat-system-header:hover strong { 4971 color: rgba(210, 170, 255, 1); 4972 } 4973 4974 .chat-preview-system .module-caption { 4975 background: linear-gradient(135deg, #252540 0%, #1a1230 100%); 4976 color: rgba(160, 120, 255, 0.8); 4977 } 4978 4979 .chat-preview-system .chat-messages { 4980 background: rgba(20, 18, 35, 0.9); 4981 } 4982 4983 .chat-preview-system .chat-handle { 4984 color: rgba(180, 140, 255, 0.95); 4985 } 4986 4987 .chat-preview-system .chat-handle:hover { 4988 color: var(--pink); 4989 } 4990 4991 /* KidLisp Preview - Full-bleed animated webp like shop */ 4992 .kidlisp-preview { 4993 background: #0d0d1a; 4994 border: none; 4995 border-radius: 8px; 4996 overflow: hidden; 4997 aspect-ratio: 1; 4998 display: flex; 4999 flex-direction: column; 5000 position: relative; 5001 /* Safari mobile fix for border-radius */ 5002 -webkit-mask-image: -webkit-radial-gradient(white, black); 5003 isolation: isolate; 5004 /* Enable transform for bounce animation */ 5005 transform-origin: center center; 5006 transition: transform 0.1s ease; 5007 } 5008 5009 /* Bounce press animation on slide change */ 5010 .kidlisp-preview.bounce-press { 5011 animation: kidlispBouncePress 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); 5012 } 5013 5014 @keyframes kidlispBouncePress { 5015 0% { transform: scale(1) rotateX(0deg) rotateY(0deg); } 5016 15% { transform: scale(0.96) rotateX(2deg) rotateY(-1deg); } 5017 30% { transform: scale(0.97) rotateX(-1deg) rotateY(1deg); } 5018 50% { transform: scale(1.02) rotateX(0deg) rotateY(0deg); } 5019 70% { transform: scale(0.99) rotateX(0.5deg) rotateY(-0.5deg); } 5020 100% { transform: scale(1) rotateX(0deg) rotateY(0deg); } 5021 } 5022 5023 .kidlisp-preview-header:hover .kidlisp-logo { 5024 filter: brightness(1.2); 5025 } 5026 5027 .kidlisp-preview .module-caption { 5028 background: linear-gradient(135deg, rgba(20,10,30,0.85) 0%, rgba(10,5,20,0.9) 100%); 5029 color: rgba(200,180,255,0.9); 5030 z-index: 10; 5031 } 5032 5033 /* KidLisp auto-advance progress bar */ 5034 .kidlisp-auto-progress { 5035 position: absolute; 5036 bottom: 0; 5037 left: 0; 5038 right: 0; 5039 height: 3px; 5040 background: rgba(255, 255, 255, 0.15); 5041 z-index: 15; 5042 overflow: hidden; 5043 } 5044 5045 .kidlisp-auto-progress-bar { 5046 height: 100%; 5047 width: 0%; 5048 background: #AA96DA; 5049 /* No transition - smooth continuous updates from requestAnimationFrame */ 5050 } 5051 5052 .kidlisp-preview-header { 5053 position: absolute; 5054 top: 0; 5055 left: 0; 5056 right: 0; 5057 padding: 0.8em 1em; 5058 display: flex; 5059 align-items: center; 5060 justify-content: space-between; 5061 gap: 0.5em; 5062 text-decoration: none; 5063 z-index: 10; 5064 background: linear-gradient(135deg, rgba(20,10,30,0.85) 0%, rgba(10,5,20,0.8) 100%); 5065 } 5066 5067 .kidlisp-preview-header:hover { 5068 text-decoration: none; 5069 } 5070 5071 .kidlisp-preview-header:hover .kidlisp-logo { 5072 text-decoration: none; 5073 } 5074 5075 /* Header code display - shows current $code */ 5076 .kidlisp-header-code-wrap { 5077 display: flex; 5078 align-items: center; 5079 gap: 0.4em; 5080 flex: 1; 5081 min-width: 0; 5082 overflow: hidden; 5083 } 5084 5085 .kidlisp-header-sep { 5086 color: var(--dim); 5087 font-size: 0.9em; 5088 opacity: 0.6; 5089 } 5090 5091 .kidlisp-header-code { 5092 font-family: 'Comic Relief', 'Comic Sans MS', cursive; 5093 font-size: 1em; 5094 white-space: nowrap; 5095 letter-spacing: -0.03em; 5096 } 5097 5098 .kidlisp-header-code .code-char { 5099 display: inline-block; 5100 } 5101 5102 .kidlisp-header-code .code-dollar { 5103 color: #32cd32; /* limegreen - brighter */ 5104 } 5105 5106 .kidlisp-header-code .code-name { 5107 color: #90EE90; /* lightgreen - softer */ 5108 } 5109 5110 /* Stale code chars blast off and fade - POOF! */ 5111 .kidlisp-header-code.transitioning .code-char { 5112 animation: kidlispCharBlastOff 0.4s ease-out forwards; 5113 } 5114 5115 /* Stagger the blast-off for each character */ 5116 .kidlisp-header-code.transitioning .code-char:nth-child(1) { animation-delay: 0ms; } 5117 .kidlisp-header-code.transitioning .code-char:nth-child(2) { animation-delay: 30ms; } 5118 .kidlisp-header-code.transitioning .code-char:nth-child(3) { animation-delay: 50ms; } 5119 .kidlisp-header-code.transitioning .code-char:nth-child(4) { animation-delay: 70ms; } 5120 .kidlisp-header-code.transitioning .code-char:nth-child(5) { animation-delay: 90ms; } 5121 .kidlisp-header-code.transitioning .code-char:nth-child(6) { animation-delay: 110ms; } 5122 .kidlisp-header-code.transitioning .code-char:nth-child(7) { animation-delay: 130ms; } 5123 .kidlisp-header-code.transitioning .code-char:nth-child(8) { animation-delay: 150ms; } 5124 .kidlisp-header-code.transitioning .code-char:nth-child(9) { animation-delay: 170ms; } 5125 .kidlisp-header-code.transitioning .code-char:nth-child(10) { animation-delay: 190ms; } 5126 5127 /* Randomize rotation direction per character */ 5128 .kidlisp-header-code.transitioning .code-char:nth-child(odd) { --blast-rotate: 15deg; } 5129 .kidlisp-header-code.transitioning .code-char:nth-child(even) { --blast-rotate: -15deg; } 5130 .kidlisp-header-code.transitioning .code-char:nth-child(3n) { --blast-rotate: 25deg; } 5131 .kidlisp-header-code.transitioning .code-char:nth-child(3n+1) { --blast-rotate: -20deg; } 5132 5133 /* Green play button indicator (like kidlisp.com editor) */ 5134 .kidlisp-play-indicator { 5135 width: 24px; 5136 height: 24px; 5137 background: rgb(76, 175, 80); 5138 border-radius: 50%; 5139 display: flex; 5140 align-items: center; 5141 justify-content: center; 5142 box-shadow: 0 2px 4px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.2); 5143 flex-shrink: 0; 5144 transition: all 0.2s ease; 5145 } 5146 5147 .kidlisp-play-indicator::before { 5148 content: ''; 5149 width: 0; 5150 height: 0; 5151 border-left: 8px solid white; 5152 border-top: 5px solid transparent; 5153 border-bottom: 5px solid transparent; 5154 margin-left: 2px; 5155 } 5156 5157 .kidlisp-preview-header:hover .kidlisp-play-indicator { 5158 background: rgb(102, 187, 106); 5159 transform: scale(1.1); 5160 box-shadow: 0 3px 6px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.3); 5161 } 5162 5163 /* Single rotating KidLisp preview carousel */ 5164 .kidlisp-carousel { 5165 position: absolute; 5166 inset: 0; 5167 overflow: hidden; 5168 } 5169 5170 .kidlisp-slide { 5171 position: absolute; 5172 inset: 0; 5173 opacity: 0; 5174 transform: translateX(30px); 5175 transition: opacity 0.5s ease, transform 0.5s ease; 5176 pointer-events: none; 5177 } 5178 5179 .kidlisp-slide.active { 5180 opacity: 1; 5181 transform: translateX(0); 5182 pointer-events: auto; 5183 } 5184 5185 .kidlisp-slide.exiting { 5186 opacity: 0; 5187 transform: translateX(-30px); 5188 } 5189 5190 /* Hide all slide content until webp is validated and ready */ 5191 .kidlisp-slide .kidlisp-slide-code, 5192 .kidlisp-slide .kidlisp-slide-qr-wrap, 5193 .kidlisp-slide .kidlisp-slide-plays, 5194 .kidlisp-slide .kidlisp-slide-credit, 5195 .kidlisp-slide .module-loading { 5196 opacity: 0; 5197 transition: opacity 0.4s ease; 5198 } 5199 5200 /* Show content only when slide is marked ready (webp validated) */ 5201 .kidlisp-slide.ready .kidlisp-slide-code, 5202 .kidlisp-slide.ready .kidlisp-slide-qr-wrap, 5203 .kidlisp-slide.ready .kidlisp-slide-plays, 5204 .kidlisp-slide.ready .kidlisp-slide-credit { 5205 opacity: 1; 5206 } 5207 5208 /* Show loading spinner while validating (but not other content) */ 5209 .kidlisp-slide.validating .module-loading { 5210 opacity: 1; 5211 } 5212 5213 .kidlisp-slide a { 5214 display: block; 5215 width: 100%; 5216 height: 100%; 5217 position: absolute; 5218 inset: 0; 5219 } 5220 5221 .kidlisp-slide img { 5222 width: 100%; 5223 height: 100%; 5224 object-fit: cover; 5225 image-rendering: pixelated; 5226 opacity: 0; 5227 transition: opacity 0.5s ease; 5228 transform: scale(1.1); 5229 } 5230 5231 .kidlisp-slide img.loaded { 5232 opacity: 0.5; 5233 } 5234 5235 .kidlisp-slide:hover img.loaded { 5236 opacity: 0.7; 5237 } 5238 5239 .kidlisp-slide-code { 5240 position: absolute; 5241 top: 4em; 5242 left: 0.2em; 5243 right: 1em; 5244 bottom: 6.5em; 5245 padding: 0.4em 0.6em; 5246 font-family: var(--mono); 5247 font-size: 0.85em; 5248 line-height: 1.4; 5249 color: #fff; 5250 overflow: hidden; 5251 text-shadow: 0 1px 2px rgba(0,0,0,0.9); 5252 white-space: pre-wrap; 5253 word-break: break-word; 5254 pointer-events: none; 5255 z-index: 2; 5256 -webkit-mask-image: linear-gradient(to bottom, black 0%, black 85%, transparent 100%); 5257 mask-image: linear-gradient(to bottom, black 0%, black 85%, transparent 100%); 5258 } 5259 5260 .kidlisp-slide-code-inner { 5261 /* For scrolling long code */ 5262 } 5263 5264 .kidlisp-slide-code-inner.scrolling { 5265 animation: codeScroll var(--scroll-duration, 10s) ease-in-out infinite; 5266 animation-delay: 2s; 5267 } 5268 5269 @keyframes codeScroll { 5270 0%, 15% { transform: translateY(0); } 5271 85%, 100% { transform: translateY(var(--scroll-distance, 0)); } 5272 } 5273 5274 .kidlisp-slide-credit { 5275 position: absolute; 5276 bottom: 4em; 5277 left: 1em; 5278 font-family: var(--mono); 5279 font-size: 0.8em; 5280 text-shadow: 0 1px 3px rgba(0,0,0,1), 0 0 8px rgba(0,0,0,0.9), 0 0 2px rgba(0,0,0,1); 5281 z-index: 5; 5282 } 5283 5284 .kidlisp-slide-credit .credit-handle { 5285 color: var(--pink); 5286 } 5287 5288 .kidlisp-slide-credit .credit-date { 5289 color: rgba(255,255,255,0.7); 5290 } 5291 5292 .kidlisp-slide-plays { 5293 position: absolute; 5294 bottom: 5.6em; 5295 left: 1em; 5296 font-family: var(--mono); 5297 font-size: 0.8em; 5298 color: var(--gold); 5299 text-shadow: 0 1px 3px rgba(0,0,0,1), 0 0 8px rgba(0,0,0,0.9), 0 0 2px rgba(0,0,0,1); 5300 z-index: 5; 5301 } 5302 5303 .kidlisp-slide-qr-wrap { 5304 position: absolute; 5305 bottom: 3.2em; 5306 right: 1em; 5307 display: flex; 5308 flex-direction: column; 5309 align-items: flex-end; 5310 z-index: 5; 5311 } 5312 5313 .kidlisp-slide-label { 5314 font-family: var(--mono); 5315 font-size: 0.85em; 5316 color: #fff; 5317 background: #000; 5318 padding: 0.1em 0.3em; 5319 } 5320 5321 /* Code character animation for slide transitions */ 5322 .kidlisp-slide-label .code-char { 5323 display: inline-block; 5324 } 5325 5326 /* Stale code chars blast off and fade - POOF! (from kidlisp.com) */ 5327 .kidlisp-slide-label.transitioning .code-char { 5328 animation: kidlispCharBlastOff 0.4s ease-out forwards; 5329 } 5330 5331 /* Stagger the blast-off for each character */ 5332 .kidlisp-slide-label.transitioning .code-char:nth-child(1) { animation-delay: 0ms; } 5333 .kidlisp-slide-label.transitioning .code-char:nth-child(2) { animation-delay: 30ms; } 5334 .kidlisp-slide-label.transitioning .code-char:nth-child(3) { animation-delay: 50ms; } 5335 .kidlisp-slide-label.transitioning .code-char:nth-child(4) { animation-delay: 70ms; } 5336 .kidlisp-slide-label.transitioning .code-char:nth-child(5) { animation-delay: 90ms; } 5337 .kidlisp-slide-label.transitioning .code-char:nth-child(6) { animation-delay: 110ms; } 5338 .kidlisp-slide-label.transitioning .code-char:nth-child(7) { animation-delay: 130ms; } 5339 .kidlisp-slide-label.transitioning .code-char:nth-child(8) { animation-delay: 150ms; } 5340 .kidlisp-slide-label.transitioning .code-char:nth-child(9) { animation-delay: 170ms; } 5341 .kidlisp-slide-label.transitioning .code-char:nth-child(10) { animation-delay: 190ms; } 5342 5343 @keyframes kidlispCharBlastOff { 5344 0% { 5345 opacity: 1; 5346 transform: translateY(0) scale(1) rotate(0deg); 5347 } 5348 50% { 5349 opacity: 0.6; 5350 transform: translateY(-8px) scale(1.2) rotate(var(--blast-rotate, 10deg)); 5351 } 5352 100% { 5353 opacity: 0; 5354 transform: translateY(-20px) scale(0.3) rotate(var(--blast-rotate, 15deg)); 5355 } 5356 } 5357 5358 /* Randomize rotation direction per character */ 5359 .kidlisp-slide-label.transitioning .code-char:nth-child(odd) { --blast-rotate: 15deg; } 5360 .kidlisp-slide-label.transitioning .code-char:nth-child(even) { --blast-rotate: -15deg; } 5361 .kidlisp-slide-label.transitioning .code-char:nth-child(3n) { --blast-rotate: 25deg; } 5362 .kidlisp-slide-label.transitioning .code-char:nth-child(3n+1) { --blast-rotate: -20deg; } 5363 5364 .kidlisp-slide-qr { 5365 background: #fff; 5366 padding: 2px; 5367 line-height: 0; 5368 } 5369 5370 .kidlisp-slide-qr img { 5371 display: block; 5372 image-rendering: pixelated; 5373 } 5374 5375 /* KidLisp syntax highlighting - matches kidlisp.com */ 5376 .kidlisp-slide-code .hl-comment { color: #6272a4; font-style: italic; } 5377 .kidlisp-slide-code .hl-string { color: orange; } 5378 .kidlisp-slide-code .hl-number { color: lime; } 5379 .kidlisp-slide-code .hl-keyword { color: pink; } 5380 .kidlisp-slide-code .hl-api { color: cyan; } 5381 .kidlisp-slide-code .hl-color { /* inline style sets actual color */ } 5382 .kidlisp-slide-code .hl-timing { color: #ffb86c; font-weight: bold; } 5383 .kidlisp-slide-code .hl-paren { color: #888; } 5384 .kidlisp-slide-code .hl-fade { font-weight: bold; } 5385 .kidlisp-slide-code .hl-code-ref { color: limegreen; font-weight: bold; } /* $code references */ 5386 .kidlisp-slide-code .hl-code-id { color: lime; } /* identifier part of $code */ 5387 .kidlisp-slide-code .hl-paint-ref { color: magenta; font-weight: bold; } /* # symbol */ 5388 .kidlisp-slide-code .hl-paint-id { color: orange; } /* identifier part of #hashtag */ 5389 .kidlisp-slide-code .hl-fade-sep { color: mediumseagreen; } /* - separator in fade */ 5390 .kidlisp-slide-code .hl-fade-dir { color: cyan; } /* direction in fade */ 5391 .kidlisp-slide-code .hl-fade-colon { color: lime; } /* : in fade */ 5392 5393 /* Rainbow text animation - per character cycling */ 5394 @keyframes rainbowCycle { 5395 0% { color: red; } 5396 14% { color: orange; } 5397 28% { color: yellow; } 5398 42% { color: lime; } 5399 57% { color: cyan; } 5400 71% { color: blue; } 5401 85% { color: magenta; } 5402 100% { color: red; } 5403 } 5404 5405 .hl-rainbow { 5406 animation: rainbowCycle 2s linear infinite; 5407 font-weight: bold; 5408 } 5409 5410 /* Stagger rainbow animation for each character */ 5411 .hl-rainbow-0 { animation-delay: 0s; } 5412 .hl-rainbow-1 { animation-delay: -0.28s; } 5413 .hl-rainbow-2 { animation-delay: -0.56s; } 5414 .hl-rainbow-3 { animation-delay: -0.84s; } 5415 .hl-rainbow-4 { animation-delay: -1.12s; } 5416 .hl-rainbow-5 { animation-delay: -1.40s; } 5417 .hl-rainbow-6 { animation-delay: -1.68s; } 5418 5419 /* Zebra text animation - alternating black/white */ 5420 @keyframes zebraCycle { 5421 0%, 49% { color: white; } 5422 50%, 100% { color: #333; } 5423 } 5424 5425 .hl-zebra { 5426 animation: zebraCycle 1s steps(1) infinite; 5427 font-weight: bold; 5428 } 5429 5430 .hl-zebra-0 { animation-delay: 0s; } 5431 .hl-zebra-1 { animation-delay: -0.5s; } 5432 5433 /* Timing blink animation - uses CSS custom property for duration */ 5434 @keyframes hl-timing-blink { 5435 0%, 90% { opacity: 1; } 5436 95% { opacity: 0.4; color: lime; } 5437 100% { opacity: 1; } 5438 } 5439 5440 .hl-timing-active { 5441 animation: hl-timing-blink var(--timing-duration, 1s) ease-in-out infinite; 5442 } 5443 5444 #pals { 5445 display: flex; 5446 flex-direction: column; 5447 align-items: center; 5448 justify-content: center; 5449 gap: 0.2em; 5450 grid-column: 1 / -1; 5451 color: rgba(255,255,255,0.3); 5452 font-family: var(--mono); 5453 font-size: 1em; 5454 text-decoration: none; 5455 cursor: pointer; 5456 transition: color 0.2s, filter 0.2s; 5457 padding: 0 0 1em 0; 5458 margin-top: -1em; 5459 } 5460 5461 .pals-logo-container { 5462 position: relative; 5463 display: inline-block; 5464 } 5465 5466 .pals-logo { 5467 width: 128px; 5468 height: auto; 5469 filter: grayscale(100%) opacity(0.3); 5470 transition: filter 0.3s, opacity 0.3s; 5471 } 5472 5473 .pals-logo-pink { 5474 position: absolute; 5475 top: 0; 5476 left: 0; 5477 width: 128px; 5478 height: auto; 5479 opacity: 0; 5480 transition: opacity 0.3s; 5481 filter: hue-rotate(-30deg) saturate(1.5) brightness(1.2) drop-shadow(0 0 8px rgba(255, 100, 200, 0.8)) drop-shadow(0 0 16px rgba(255, 100, 200, 0.5)); 5482 } 5483 5484 @keyframes pals-energy { 5485 0%, 100% { 5486 transform: rotate(0deg) scale(1); 5487 filter: drop-shadow(0 0 8px rgba(255, 100, 200, 0.8)) drop-shadow(0 0 16px rgba(255, 100, 200, 0.5)); 5488 } 5489 10% { transform: rotate(-4deg) scale(1.02); } 5490 20% { transform: rotate(3deg) scale(0.98); } 5491 30% { transform: rotate(-3deg) scale(1.03); } 5492 40% { transform: rotate(4deg) scale(0.97); } 5493 50% { 5494 transform: rotate(-2deg) scale(1.02); 5495 filter: drop-shadow(0 0 12px rgba(255, 100, 200, 1)) drop-shadow(0 0 24px rgba(255, 100, 200, 0.7)); 5496 } 5497 60% { transform: rotate(3deg) scale(0.99); } 5498 70% { transform: rotate(-4deg) scale(1.01); } 5499 80% { transform: rotate(2deg) scale(1.03); } 5500 90% { transform: rotate(-3deg) scale(0.98); } 5501 } 5502 5503 @keyframes pals-shake { 5504 0%, 100% { transform: rotate(0deg); } 5505 20% { transform: rotate(-3deg); } 5506 40% { transform: rotate(3deg); } 5507 60% { transform: rotate(-2deg); } 5508 80% { transform: rotate(2deg); } 5509 } 5510 5511 #pals span { 5512 order: 2; 5513 } 5514 5515 #pals:hover { 5516 color: rgba(255,255,255,0.8); 5517 } 5518 5519 #pals:hover .pals-logo { 5520 filter: grayscale(100%) opacity(0.15); 5521 } 5522 5523 #pals:hover .pals-logo-pink { 5524 opacity: 1; 5525 animation: pals-energy 0.6s ease-in-out infinite; 5526 } 5527 5528 .page-footer { 5529 grid-column: 1 / -1; 5530 text-align: center; 5531 padding: 3.5em 0 0.5em 0; 5532 font-family: var(--mono); 5533 font-size: 0.85em; 5534 color: rgba(255,255,255,0.25); 5535 } 5536 5537 .page-footer a { 5538 color: rgba(255,255,255,0.4); 5539 text-decoration: none; 5540 } 5541 5542 .page-footer a:hover { 5543 color: rgba(255,255,255,0.7); 5544 } 5545 5546 /* Footer auth section */ 5547 .footer-auth { 5548 display: flex; 5549 justify-content: center; 5550 align-items: center; 5551 gap: 12px; 5552 margin-bottom: 0.5em; 5553 } 5554 5555 .footer-auth-buttons { 5556 display: flex; 5557 gap: 16px; 5558 } 5559 5560 .footer-login-btn { 5561 padding: 10px 20px; 5562 background: rgba(30, 50, 120, 0.95); 5563 color: white; 5564 border: 1px solid white; 5565 border-radius: 0; 5566 font-size: 14px; 5567 font-weight: normal; 5568 cursor: pointer; 5569 transition: all 0.15s; 5570 font-family: var(--mono); 5571 box-shadow: 0 0 10px rgba(80, 120, 255, 0.25), 0 0 20px rgba(80, 120, 255, 0.1); 5572 } 5573 5574 .footer-login-btn:hover { 5575 background: rgba(40, 60, 140, 0.95); 5576 box-shadow: 0 0 15px rgba(100, 150, 255, 0.4), 0 0 30px rgba(100, 150, 255, 0.2); 5577 } 5578 5579 .footer-signup-btn { 5580 padding: 10px 20px; 5581 background: rgba(30, 90, 50, 0.95); 5582 color: white; 5583 border: 1px solid white; 5584 border-radius: 0; 5585 font-size: 14px; 5586 font-weight: normal; 5587 cursor: pointer; 5588 transition: all 0.15s; 5589 font-family: var(--mono); 5590 box-shadow: 0 0 10px rgba(80, 200, 120, 0.25), 0 0 20px rgba(80, 200, 120, 0.1); 5591 } 5592 5593 .footer-signup-btn:hover { 5594 background: rgba(40, 110, 60, 0.95); 5595 box-shadow: 0 0 15px rgba(100, 255, 150, 0.4), 0 0 30px rgba(100, 255, 150, 0.2); 5596 } 5597 5598 .footer-user-menu { 5599 display: flex; 5600 align-items: center; 5601 gap: 4px; 5602 padding: 10px 20px; 5603 background: black; 5604 border: 1px solid white; 5605 border-radius: 0; 5606 cursor: pointer; 5607 transition: all 0.15s; 5608 font-family: var(--mono); 5609 text-decoration: none; 5610 } 5611 5612 .footer-user-menu:hover { 5613 background: rgb(40, 40, 40); 5614 } 5615 5616 .footer-user-handle { 5617 font-size: 14px; 5618 color: white; 5619 font-weight: normal; 5620 font-family: var(--mono); 5621 } 5622 5623 .footer-user-stack { 5624 display: flex; 5625 flex-direction: column; 5626 align-items: center; 5627 gap: 4px; 5628 } 5629 5630 .footer-logout-link { 5631 display: block; 5632 margin-top: 6px; 5633 font-size: 10px; 5634 color: rgba(255, 255, 255, 0.3); 5635 text-decoration: none; 5636 font-family: var(--mono); 5637 } 5638 5639 .footer-logout-link:hover { 5640 color: rgba(255, 255, 255, 0.6); 5641 } 5642 5643 /* Mobile adjustments */ 5644 @media (max-width: 699px) { 5645 .stats-section { 5646 grid-template-columns: repeat(2, 1fr); 5647 } 5648 } 5649 5650 @media (max-width: 400px) { 5651 .stats-section { 5652 grid-template-columns: repeat(2, 1fr); 5653 } 5654 .stat-item { min-width: unset; } 5655 } 5656 5657 /* Thank You Mode */ 5658 .thanks-section { 5659 display: none; 5660 grid-column: 1 / -1; 5661 text-align: center; 5662 padding: 2em 1em; 5663 grid-row: 2; /* After ticker */ 5664 } 5665 5666 body.thanks-mode .thanks-section { 5667 display: block; 5668 } 5669 5670 body.thanks-mode .fiat-section { 5671 grid-row: 1; /* Ticker first */ 5672 } 5673 5674 body.thanks-mode .fiat-section .currency-picker { 5675 display: none !important; 5676 } 5677 5678 body.thanks-mode .fiat-section .currency-pickers { 5679 display: none !important; 5680 } 5681 5682 body.thanks-mode .currency-links { 5683 display: none !important; 5684 } 5685 5686 .thanks-canvas-wrap { 5687 display: inline-block; 5688 margin-bottom: 1.5em; 5689 } 5690 5691 .thanks-canvas { 5692 width: 400px; 5693 height: 400px; 5694 border-radius: 12px; 5695 border: 4px solid var(--pink); 5696 box-shadow: 5697 0 0 20px rgba(255, 107, 157, 0.4), 5698 0 0 40px rgba(255, 107, 157, 0.2), 5699 0 8px 32px rgba(0, 0, 0, 0.3); 5700 animation: celebrateSwivel 8s ease-in-out infinite; 5701 transition: transform 0.3s ease, box-shadow 0.3s ease; 5702 } 5703 5704 .thanks-canvas:hover { 5705 transform: scale(1.02) rotate(1deg); 5706 box-shadow: 5707 0 0 30px rgba(255, 107, 157, 0.6), 5708 0 0 60px rgba(255, 107, 157, 0.3), 5709 0 12px 48px rgba(0, 0, 0, 0.4); 5710 } 5711 5712 @keyframes celebrateSwivel { 5713 0%, 100% { transform: rotate(-1deg) scale(1); } 5714 25% { transform: rotate(0.5deg) scale(1.01); } 5715 50% { transform: rotate(1deg) scale(1); } 5716 75% { transform: rotate(-0.5deg) scale(1.01); } 5717 } 5718 5719 .thanks-message { 5720 5721 font-family: 'YWFTProcessing-Regular', sans-serif; 5722 font-size: 1.4em; 5723 color: var(--text); 5724 max-width: 500px; 5725 margin: 0 auto 1.5em auto; 5726 line-height: 1.5; 5727 } 5728 5729 .thanks-amount { 5730 color: var(--green); 5731 font-weight: bold; 5732 } 5733 5734 .thanks-actions { 5735 display: flex; 5736 gap: 1em; 5737 justify-content: center; 5738 flex-wrap: wrap; 5739 } 5740 5741 .thanks-again { 5742 display: inline-block; 5743 padding: 0.8em 1.5em; 5744 background: var(--box-bg); 5745 border: 1px solid var(--box-border); 5746 border-radius: 6px; 5747 color: var(--cyan); 5748 text-decoration: none; 5749 transition: all 0.15s; 5750 font-size: 0.95em; 5751 } 5752 5753 .thanks-again:hover { 5754 background: var(--cyan); 5755 color: var(--bg); 5756 text-decoration: none; 5757 } 5758 5759 .thanks-share { 5760 display: inline-block; 5761 padding: 0.8em 1.5em; 5762 background: #000; 5763 border: 1px solid #333; 5764 border-radius: 6px; 5765 color: #fff; 5766 font-family: var(--mono); 5767 font-size: 0.95em; 5768 cursor: pointer; 5769 transition: all 0.15s; 5770 } 5771 5772 .thanks-share:hover { 5773 background: #1da1f2; 5774 border-color: #1da1f2; 5775 } 5776 5777 /* Thanks page mobile responsive */ 5778 @media (max-width: 768px) { 5779 .thanks-message { 5780 font-size: 1.2em; 5781 max-width: 90%; 5782 padding: 0 1em; 5783 } 5784 .thanks-actions { 5785 gap: 0.8em; 5786 } 5787 .thanks-again, .thanks-share { 5788 padding: 0.7em 1.2em; 5789 font-size: 0.9em; 5790 } 5791 } 5792 5793 @media (max-width: 480px) { 5794 .thanks-message { 5795 font-size: 1em; 5796 max-width: 95%; 5797 padding: 0 0.5em; 5798 } 5799 .thanks-actions { 5800 flex-direction: column; 5801 gap: 0.6em; 5802 align-items: center; 5803 } 5804 .thanks-again, .thanks-share { 5805 padding: 0.6em 1em; 5806 font-size: 0.85em; 5807 width: 100%; 5808 max-width: 250px; 5809 text-align: center; 5810 } 5811 } 5812 5813 /* Ken Burns Canvas Slideshow - Nostalgic Photo Background */ 5814 .jeffreys-slideshow { 5815 position: absolute; 5816 top: 0; 5817 left: 0; 5818 right: 0; 5819 bottom: 0; 5820 overflow: hidden; 5821 border-radius: 0; 5822 z-index: 0; 5823 } 5824 5825 .jeffreys-canvas { 5826 position: absolute; 5827 /* Center the oversized canvas */ 5828 top: 50%; 5829 left: 50%; 5830 transform: translate(-50%, -50%); 5831 width: calc(100% + 20px); 5832 height: calc(100% + 20px); 5833 /* Sharp and crisp with slight darkening */ 5834 filter: url(#sharpen) saturate(0.95) brightness(0.75); 5835 image-rendering: -webkit-optimize-contrast; /* Better scaling on iOS */ 5836 image-rendering: crisp-edges; 5837 z-index: 4; /* Above floating messages */ 5838 } 5839 5840 /* Subtle color tint */ 5841 .jeffreys-slideshow::before { 5842 content: ''; 5843 position: absolute; 5844 inset: 0; 5845 background: linear-gradient( 5846 135deg, 5847 rgba(148, 0, 211, 0.15) 0%, 5848 rgba(205, 92, 155, 0.18) 50%, 5849 rgba(138, 43, 226, 0.12) 100% 5850 ); 5851 mix-blend-mode: overlay; 5852 pointer-events: none; 5853 z-index: 5; 5854 } 5855 5856 /* Subtle vignette */ 5857 .jeffreys-slideshow::after { 5858 content: ''; 5859 position: absolute; 5860 inset: 0; 5861 background: radial-gradient(ellipse at center, transparent 40%, rgba(26, 26, 46, 0.35) 100%); 5862 pointer-events: none; 5863 z-index: 6; 5864 } 5865 5866 /* Make sure gift-logo-wrap stays above slideshow */ 5867 .gift-logo-wrap { 5868 position: relative; 5869 z-index: 4; 5870 } 5871 5872 /* Logo with stronger dark shadow on slideshow background */ 5873 .gift-visual:has(.jeffreys-slideshow) .gift-logo { 5874 filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.8)) drop-shadow(0 4px 10px rgba(0, 0, 0, 0.6)) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4)); 5875 } 5876 5877 /* Floating give notes on jeffreys slideshow - alphabet soup particle system */ 5878 .floating-give { 5879 position: absolute; 5880 font-family: var(--mono), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Android Emoji", "EmojiSymbols", sans-serif; 5881 font-size: 1.1em; 5882 color: #fff; 5883 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); 5884 pointer-events: none; 5885 white-space: nowrap; 5886 max-width: 55%; 5887 overflow: visible; 5888 opacity: 0; 5889 animation: floatAmbient 30s ease-in-out forwards; 5890 transform-origin: center center; 5891 letter-spacing: 0.1em; 5892 -webkit-font-smoothing: antialiased; 5893 -moz-osx-font-smoothing: grayscale; 5894 z-index: 10; /* Above canvas (z-index: 4) */ 5895 } 5896 5897 .floating-give.left-side { 5898 left: 5%; 5899 text-align: left; 5900 } 5901 5902 .floating-give.right-side { 5903 right: 15%; 5904 text-align: right; 5905 } 5906 5907 .floating-give.center-side { 5908 left: 45%; 5909 transform: translateX(-50%); 5910 text-align: center; 5911 } 5912 5913 .floating-give .char { 5914 display: inline-block; 5915 animation: charSoup var(--char-duration) ease-in-out infinite; 5916 animation-delay: var(--char-delay); 5917 transform-origin: center bottom; 5918 position: relative; 5919 z-index: var(--char-z, 0); 5920 } 5921 5922 .floating-give .got { 5923 color: var(--cyan); 5924 margin-right: 0.3em; 5925 } 5926 5927 .floating-give .amount { 5928 color: var(--green); 5929 font-weight: bold; 5930 margin-right: 0.3em; 5931 font-size: 1em; 5932 letter-spacing: 0.08em; 5933 } 5934 5935 .floating-give .amount .char { 5936 animation: charSoupBig var(--char-duration) ease-in-out infinite; 5937 } 5938 5939 .floating-give.monthly .amount { 5940 color: var(--gold); 5941 } 5942 5943 .floating-give .note { 5944 color: var(--gold); 5945 font-size: 1.05em; 5946 letter-spacing: 0.08em; 5947 animation: noteColorPulse 3s ease-in-out infinite; 5948 } 5949 5950 @keyframes noteColorPulse { 5951 0%, 100% { 5952 color: var(--gold); 5953 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); 5954 } 5955 33% { 5956 color: #fff; 5957 text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 8px rgba(255, 215, 61, 0.4); 5958 } 5959 66% { 5960 color: var(--cyan); 5961 text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); 5962 } 5963 } 5964 5965 .floating-give .time { 5966 margin-right: 0.3em; 5967 color: #fff; 5968 font-size: 0.85em; 5969 letter-spacing: 0.06em; 5970 margin-left: 0.3em; 5971 } 5972 5973 @keyframes floatAmbient { 5974 0% { 5975 opacity: 0; 5976 transform: translateY(0) translateX(var(--drift-start, 0px)); 5977 } 5978 5% { 5979 opacity: 0.9; 5980 } 5981 15% { 5982 transform: translateY(-50px) translateX(calc(var(--drift-start, 0px) + 25px)); 5983 } 5984 30% { 5985 transform: translateY(-120px) translateX(calc(var(--drift-start, 0px) - 35px)); 5986 } 5987 45% { 5988 transform: translateY(-200px) translateX(calc(var(--drift-start, 0px) + 40px)); 5989 } 5990 60% { 5991 transform: translateY(-300px) translateX(calc(var(--drift-start, 0px) - 30px)); 5992 } 5993 75% { 5994 transform: translateY(-420px) translateX(calc(var(--drift-start, 0px) + 20px)); 5995 } 5996 90% { 5997 opacity: 0.9; 5998 } 5999 100% { 6000 opacity: 0; 6001 transform: translateY(-550px) translateX(var(--drift-end, 0px)); 6002 } 6003 } 6004 6005 /* Alphabet soup - gentle letter sway */ 6006 @keyframes charSoup { 6007 0%, 100% { 6008 transform: translateY(0) rotate(0deg); 6009 } 6010 25% { 6011 transform: translateY(-2px) translateX(1px) rotate(1.5deg); 6012 } 6013 50% { 6014 transform: translateY(-1px) translateX(-1px) rotate(-1deg); 6015 } 6016 75% { 6017 transform: translateY(-2px) translateX(0.5px) rotate(1deg); 6018 } 6019 } 6020 6021 /* Bigger swing for amount letters */ 6022 @keyframes charSoupBig { 6023 0%, 100% { 6024 transform: translateY(0) rotate(var(--rot-base, 0deg)) scale(1); 6025 } 6026 15% { 6027 transform: translateY(calc(var(--swing-y, -8px) * 1.5)) translateX(calc(var(--swing-x, 3px) * 1.3)) rotate(calc(var(--rot-base, 0deg) + var(--rot-swing, 4deg) * 1.2)) scale(1.02); 6028 } 6029 35% { 6030 transform: translateY(calc(var(--swing-y, -8px) * 0.9)) translateX(calc(var(--swing-x, 3px) * -1.1)) rotate(calc(var(--rot-base, 0deg) - var(--rot-swing, 4deg) * 0.9)) scale(0.98); 6031 } 6032 55% { 6033 transform: translateY(calc(var(--swing-y, -8px) * 1.3)) translateX(calc(var(--swing-x, 3px) * 0.8)) rotate(calc(var(--rot-base, 0deg) + var(--rot-swing, 4deg) * 0.7)) scale(1.01); 6034 } 6035 75% { 6036 transform: translateY(calc(var(--swing-y, -8px) * 0.6)) translateX(calc(var(--swing-x, 3px) * -0.5)) rotate(calc(var(--rot-base, 0deg) - var(--rot-swing, 4deg) * 0.4)) scale(0.99); 6037 } 6038 } 6039 6040 /* Responsive floating gives - smaller on mobile */ 6041 @media (max-width: 768px) { 6042 .floating-give { 6043 font-size: 0.85em; 6044 letter-spacing: 0.05em; 6045 } 6046 .floating-give .amount { 6047 font-size: 0.95em; 6048 } 6049 .floating-give .note { 6050 font-size: 0.9em; 6051 } 6052 } 6053 6054 @media (max-width: 480px) { 6055 .floating-give { 6056 font-size: 0.75em; 6057 letter-spacing: 0.02em; 6058 max-width: 70%; 6059 } 6060 .floating-give .amount { 6061 font-size: 0.85em; 6062 margin-right: 0.2em; 6063 } 6064 .floating-give .note { 6065 font-size: 0.8em; 6066 } 6067 } 6068 6069 /* ================================================ 6070 LIGHT MODE THEME OVERRIDES 6071 Based on kidlisp.com and at.aesthetic.computer 6072 ================================================ */ 6073 6074 6075 /* Light mode: Top bar */ 6076 body.light-mode .top-bar { 6077 background: linear-gradient(to bottom, var(--bg) 0%, var(--bg) 70%, transparent 100%); 6078 } 6079 6080 /* Light mode: Logo adjustments */ 6081 body.light-mode .logo-give { 6082 color: var(--text); 6083 } 6084 6085 body.light-mode .logo-sep { 6086 color: var(--dim); 6087 } 6088 6089 /* Light mode: Prose/content boxes */ 6090 body.light-mode .prose { 6091 background: white; 6092 border-color: var(--box-border); 6093 box-shadow: var(--shadow-soft); 6094 } 6095 6096 /* Light mode: Ticker */ 6097 body.light-mode .gives-ticker { 6098 background: linear-gradient(180deg, rgba(0, 100, 50, 0.08) 0%, rgba(0, 50, 25, 0.12) 100%); 6099 border-color: rgba(0, 150, 100, 0.25); 6100 } 6101 6102 body.light-mode .gives-ticker-item { 6103 border-right-color: rgba(0, 150, 100, 0.15); 6104 } 6105 6106 /* Light mode: Gift widget visual */ 6107 body.light-mode .gift-visual { 6108 background: linear-gradient(135deg, rgba(205, 92, 155, 0.08) 0%, rgba(0, 180, 180, 0.05) 100%); 6109 border-color: var(--box-border); 6110 } 6111 6112 /* Light mode: Slider/range */ 6113 body.light-mode .gift-controls input[type="range"] { 6114 background: var(--slider-track); 6115 } 6116 6117 /* Light mode: Gift amount display */ 6118 body.light-mode .gift-amount { 6119 background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); 6120 border-color: var(--green); 6121 color: #1b5e20; 6122 } 6123 6124 body.light-mode .gift-widget.monthly-mode .gift-amount { 6125 background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%); 6126 border-color: var(--gold); 6127 color: #e65100; 6128 } 6129 6130 /* Light mode: Gift button */ 6131 body.light-mode .gift-btn { 6132 background: linear-gradient(180deg, var(--green) 0%, #047857 100%); 6133 border-color: #10b981; 6134 box-shadow: var(--shadow-medium); 6135 } 6136 6137 body.light-mode .gift-btn:hover { 6138 background: linear-gradient(180deg, var(--pink) 0%, rgb(175, 72, 130) 100%); 6139 border-color: rgb(225, 112, 175); 6140 } 6141 6142 body.light-mode .gift-widget.monthly-mode .gift-btn { 6143 background: linear-gradient(180deg, var(--gold) 0%, #b45309 100%); 6144 border-color: #f59e0b; 6145 color: white; 6146 } 6147 6148 /* Light mode: Stat items */ 6149 body.light-mode .stat-item { 6150 background: white; 6151 border-color: var(--box-border); 6152 box-shadow: var(--shadow-soft); 6153 } 6154 6155 body.light-mode #stat-handles { background: rgba(59, 130, 246, 0.08); } 6156 body.light-mode #stat-paintings { background: rgba(249, 115, 22, 0.08); } 6157 body.light-mode #stat-moods { background: rgba(236, 72, 153, 0.08); } 6158 body.light-mode #stat-kidlisp { background: rgba(139, 92, 246, 0.08); } 6159 body.light-mode #stat-commands { background: rgba(34, 197, 94, 0.08); } 6160 body.light-mode #stat-messages { background: rgba(234, 179, 8, 0.08); } 6161 6162 body.light-mode .stat-value { 6163 text-shadow: none; 6164 } 6165 6166 body.light-mode .stat-label { 6167 text-shadow: none; 6168 } 6169 6170 /* Light mode: Module boxes (shop, kidlisp, etc.) */ 6171 body.light-mode .shop-module, 6172 body.light-mode .kidlisp-module, 6173 body.light-mode .apps-flip-face, 6174 body.light-mode .hacker-card, 6175 body.light-mode .feature-module { 6176 background: white; 6177 border-color: var(--box-border); 6178 box-shadow: var(--shadow-soft); 6179 } 6180 6181 /* Light mode: Desktop face (VSCode/Ableton) */ 6182 body.light-mode .desktop-face { 6183 background: linear-gradient(135deg, #f8f8f8 0%, #eeeeee 100%); 6184 border-color: var(--box-border); 6185 } 6186 6187 body.light-mode .desktop-face-header { 6188 border-bottom-color: var(--box-border); 6189 } 6190 6191 body.light-mode .face-label { 6192 color: var(--dim); 6193 } 6194 6195 body.light-mode .desktop-vscode-link, 6196 body.light-mode .desktop-ableton-link { 6197 background: rgba(0, 0, 0, 0.04); 6198 border-color: var(--box-border); 6199 } 6200 6201 body.light-mode .desktop-vscode-link:hover, 6202 body.light-mode .desktop-ableton-link:hover { 6203 background: rgba(0, 0, 0, 0.08); 6204 border-color: var(--dim); 6205 } 6206 6207 body.light-mode .desktop-vscode-link .vscode-label, 6208 body.light-mode .desktop-ableton-link .ableton-label { 6209 color: var(--text); 6210 } 6211 6212 body.light-mode .desktop-ableton-link .ableton-tag { 6213 background: rgba(0, 0, 0, 0.08); 6214 color: var(--dim); 6215 } 6216 6217 body.light-mode .desktop-header-ticker-content { 6218 color: var(--dim); 6219 } 6220 6221 /* Light mode: Mobile face */ 6222 body.light-mode .mobile-face { 6223 background: linear-gradient(135deg, #f8f8f8 0%, #eeeeee 100%); 6224 border-color: var(--box-border); 6225 } 6226 6227 body.light-mode .mobile-face-header { 6228 border-bottom-color: var(--box-border); 6229 } 6230 6231 body.light-mode .phone-frame { 6232 background: #333; 6233 } 6234 6235 /* Light mode: Links */ 6236 body.light-mode a { 6237 color: var(--cyan); 6238 } 6239 6240 body.light-mode a.handle-link { 6241 color: var(--pink); 6242 } 6243 6244 body.light-mode code { 6245 background: rgba(205, 92, 155, 0.1); 6246 color: var(--pink); 6247 } 6248 6249 /* Light mode: Crypto section */ 6250 body.light-mode .crypto-header { 6251 background: linear-gradient(135deg, rgba(139, 92, 246, 0.12) 0%, rgba(0, 180, 180, 0.06) 100%); 6252 } 6253 6254 body.light-mode .crypto-address { 6255 background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(0, 180, 180, 0.05) 100%); 6256 border-color: rgba(34, 197, 94, 0.3); 6257 } 6258 6259 body.light-mode .crypto-address:hover { 6260 border-color: var(--green); 6261 background: linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(0, 180, 180, 0.1) 100%); 6262 } 6263 6264 body.light-mode .crypto-addr { 6265 background: rgba(0, 50, 30, 0.9); 6266 color: #00ff88; 6267 } 6268 6269 body.light-mode .crypto-controls.crypto-all .crypto-address[data-crypto="xtz"] { 6270 background: linear-gradient(180deg, rgba(139, 92, 246, 0.15) 0%, rgba(109, 62, 206, 0.2) 100%); 6271 } 6272 6273 body.light-mode .crypto-controls.crypto-all .crypto-address[data-crypto="eth"] { 6274 background: linear-gradient(180deg, rgba(59, 130, 246, 0.15) 0%, rgba(37, 99, 235, 0.2) 100%); 6275 } 6276 6277 body.light-mode .crypto-controls.crypto-all .crypto-address[data-crypto="btc"] { 6278 background: linear-gradient(180deg, rgba(249, 115, 22, 0.15) 0%, rgba(234, 88, 12, 0.2) 100%); 6279 } 6280 6281 /* Light mode: PayPal section */ 6282 body.light-mode .paypal-header { 6283 background: linear-gradient(135deg, rgba(0, 112, 186, 0.12) 0%, rgba(0, 48, 135, 0.08) 100%); 6284 } 6285 6286 body.light-mode .paypal-controls { 6287 background: linear-gradient(180deg, rgba(0, 112, 186, 0.05) 0%, rgba(0, 48, 135, 0.08) 100%); 6288 } 6289 6290 /* Light mode: Currency links */ 6291 body.light-mode .curr-link { 6292 color: var(--dim); 6293 } 6294 6295 body.light-mode .curr-link:hover { 6296 color: var(--text); 6297 } 6298 6299 body.light-mode .curr-link.active[data-curr="usd"] { color: var(--green); } 6300 body.light-mode .curr-link.active[data-curr="dkk"] { color: var(--cyan); } 6301 body.light-mode .curr-link.active[data-curr="crypto"] { color: #7c3aed; } 6302 body.light-mode .curr-link.active[data-curr="paypal"] { color: #0070ba; } 6303 body.light-mode .curr-link.active[data-curr="liberapay"] { color: #d4a800; } 6304 6305 /* Light mode: Liberapay section */ 6306 body.light-mode .liberapay-header { 6307 background: linear-gradient(135deg, rgba(246, 201, 21, 0.12) 0%, rgba(200, 160, 0, 0.08) 100%); 6308 } 6309 6310 body.light-mode .liberapay-controls { 6311 background: linear-gradient(180deg, rgba(246, 201, 21, 0.05) 0%, rgba(200, 160, 0, 0.08) 100%); 6312 } 6313 6314 body.light-mode .liberapay-title { 6315 color: #d4a800; 6316 } 6317 6318 /* Light mode: Language dropdown */ 6319 body.light-mode .lang-dropdown { 6320 background: white; 6321 border-color: var(--box-border); 6322 box-shadow: var(--shadow-medium); 6323 } 6324 6325 body.light-mode .lang-option:hover { 6326 background: rgba(205, 92, 155, 0.1); 6327 } 6328 6329 /* Light mode: Holiday banner */ 6330 body.light-mode .holiday-banner { 6331 background: rgba(205, 92, 155, 0.08); 6332 border-color: rgba(205, 92, 155, 0.2); 6333 } 6334 6335 /* Light mode: Login modal */ 6336 body.light-mode .login-modal { 6337 background: #fafafa; 6338 border: 1px solid rgba(0, 0, 0, 0.15); 6339 box-shadow: 6340 0 0 0 1px rgba(205, 92, 155, 0.2), 6341 0 8px 32px rgba(0, 0, 0, 0.15); 6342 } 6343 6344 body.light-mode .login-modal::backdrop { 6345 background: rgba(255, 255, 255, 0.7); 6346 backdrop-filter: blur(4px); 6347 } 6348 6349 body.light-mode .login-modal-title { 6350 color: var(--pink); 6351 } 6352 6353 body.light-mode .login-modal-text { 6354 color: rgba(0, 0, 0, 0.6); 6355 } 6356 6357 body.light-mode .login-modal-btn.secondary { 6358 color: rgba(0, 0, 0, 0.5); 6359 border-color: rgba(0, 0, 0, 0.15); 6360 } 6361 6362 body.light-mode .login-modal-btn.secondary:hover { 6363 background: rgba(0, 0, 0, 0.05); 6364 color: rgba(0, 0, 0, 0.8); 6365 border-color: rgba(0, 0, 0, 0.25); 6366 } 6367 6368 /* Light mode: Thanks section */ 6369 body.light-mode .thanks-section { 6370 background: white; 6371 } 6372 6373 /* Light mode: Footer - keep original button styles */ 6374 body.light-mode .give-footer { 6375 background: linear-gradient(to top, var(--bg) 0%, transparent 100%); 6376 } 6377 6378 /* Light mode: Add darker shadow behind footer buttons for visibility */ 6379 body.light-mode .footer-login-btn { 6380 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 10px rgba(80, 120, 255, 0.25); 6381 } 6382 6383 body.light-mode .footer-signup-btn { 6384 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 10px rgba(80, 200, 120, 0.25); 6385 } 6386 6387 /* Light mode: Monthly checkbox */ 6388 body.light-mode .gift-monthly-check input[type="checkbox"] { 6389 background: white; 6390 border-color: var(--box-border); 6391 } 6392 6393 body.light-mode .gift-monthly-check input[type="checkbox"]:checked { 6394 background: var(--gold); 6395 border-color: var(--gold); 6396 } 6397 6398 /* Light mode: Shop products */ 6399 body.light-mode .shop-product { 6400 background: #fafafa; 6401 border-color: var(--box-border); 6402 } 6403 6404 body.light-mode .shop-product-img { 6405 background: #f0f0f0; 6406 } 6407 6408 /* Light mode: KidLisp panels */ 6409 body.light-mode .kidlisp-panel { 6410 background: white; 6411 border-color: var(--box-border); 6412 } 6413 6414 body.light-mode .kidlisp-preview { 6415 background: #f8f6ff; 6416 border: 1px solid rgba(139, 92, 246, 0.2); 6417 } 6418 6419 body.light-mode .kidlisp-preview .module-caption { 6420 background: #fffacd !important; 6421 color: var(--text); 6422 border-bottom: 3px solid rgba(139, 92, 246, 0.3); 6423 } 6424 6425 body.light-mode .kidlisp-preview-header { 6426 background: #fffacd; 6427 border-bottom: 1px solid rgba(0, 0, 0, 0.1); 6428 } 6429 6430 body.light-mode .kidlisp-header-code .code-dollar { 6431 color: #22c55e; 6432 text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.3); 6433 } 6434 6435 body.light-mode .kidlisp-header-code .code-name { 6436 color: #15803d; 6437 text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2); 6438 } 6439 6440 body.light-mode .kidlisp-logo, 6441 body.light-mode .baby-colors { 6442 text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.25); 6443 } 6444 6445 /* Light mode: KidLisp slide - brighter handles/hits, full opacity images */ 6446 body.light-mode .kidlisp-slide-credit .credit-handle { 6447 color: #d946ef; 6448 text-shadow: 0 1px 3px rgba(0,0,0,0.8), 0 0 6px rgba(0,0,0,0.6); 6449 } 6450 6451 body.light-mode .kidlisp-slide-plays { 6452 color: #f59e0b; 6453 text-shadow: 0 1px 3px rgba(0,0,0,0.8), 0 0 6px rgba(0,0,0,0.6); 6454 } 6455 6456 body.light-mode .kidlisp-slide img.loaded { 6457 opacity: 0.85; 6458 } 6459 6460 body.light-mode .kidlisp-slide:hover img.loaded { 6461 opacity: 1; 6462 } 6463 6464 /* Light mode: Invest section */ 6465 body.light-mode .invest-section { 6466 background: 6467 linear-gradient(135deg, rgba(240, 253, 244, 0.95) 0%, rgba(236, 253, 245, 0.98) 100%), 6468 url('https://assets.aesthetic.computer/jeffreys/jpg/IMG_2658.jpg'); 6469 background-size: cover, 200%; 6470 background-position: center; 6471 border-color: rgba(16, 185, 129, 0.3); 6472 box-shadow: var(--shadow-soft); 6473 } 6474 6475 body.light-mode .invest-header { 6476 color: var(--cyan); 6477 } 6478 6479 body.light-mode .invest-content { 6480 color: var(--text); 6481 } 6482 6483 body.light-mode .invest-content a { 6484 color: var(--cyan); 6485 border-bottom-color: rgba(8, 145, 178, 0.4); 6486 } 6487 6488 body.light-mode .invest-content a:hover { 6489 color: #0e7490; 6490 border-bottom-color: #0e7490; 6491 } 6492 6493 body.light-mode .invest-suggestion { 6494 background: rgba(16, 185, 129, 0.1); 6495 border-color: rgba(16, 185, 129, 0.3); 6496 color: var(--text); 6497 } 6498 6499 body.light-mode .invest-suggestion a { 6500 color: var(--cyan); 6501 } 6502 6503 /* Light mode: Module captions (all types) */ 6504 body.light-mode .module-caption { 6505 background: rgba(255, 255, 255, 0.95) !important; 6506 color: var(--text); 6507 } 6508 6509 body.light-mode .module-caption .highlight { 6510 color: var(--gold); 6511 } 6512 6513 body.light-mode .cmd-highlight { 6514 color: var(--pink); 6515 } 6516 6517 /* Light mode: Chat preview boxes */ 6518 body.light-mode .chat-preview { 6519 background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); 6520 border-color: rgba(217, 119, 6, 0.25); 6521 box-shadow: var(--shadow-soft); 6522 } 6523 6524 body.light-mode .chat-preview-header { 6525 background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); 6526 } 6527 6528 body.light-mode .chat-preview-header strong { 6529 color: var(--gold); 6530 } 6531 6532 body.light-mode .chat-preview .module-caption { 6533 background: #fffacd !important; 6534 color: var(--text); 6535 } 6536 6537 body.light-mode .chat-messages { 6538 background: rgba(255, 255, 255, 0.9); 6539 } 6540 6541 body.light-mode .chat-preview .chat-msg { 6542 color: var(--text); 6543 } 6544 6545 body.light-mode .chat-preview .chat-time { 6546 color: var(--dim); 6547 } 6548 6549 body.light-mode .chat-preview .chat-handle { 6550 color: var(--pink); 6551 } 6552 6553 /* Light mode: Chat system (purple theme) */ 6554 body.light-mode .chat-preview-system { 6555 background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%); 6556 border-color: rgba(139, 92, 246, 0.25); 6557 box-shadow: var(--shadow-soft); 6558 } 6559 6560 body.light-mode .chat-system-header { 6561 background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%); 6562 } 6563 6564 body.light-mode .chat-system-header strong { 6565 color: #7c3aed; 6566 } 6567 6568 body.light-mode .chat-preview-system .module-caption { 6569 background: #ede9fe !important; 6570 color: var(--text); 6571 } 6572 6573 body.light-mode .chat-preview-system .chat-messages { 6574 background: rgba(255, 255, 255, 0.9); 6575 } 6576 6577 body.light-mode .chat-preview-system .chat-handle { 6578 color: #7c3aed; 6579 } 6580 6581 /* Light mode: Hacker card / AT Proto section - keep transparent, no shadow */ 6582 body.light-mode .hacker-card { 6583 background: transparent; 6584 border: none; 6585 box-shadow: none; 6586 color: var(--text); 6587 } 6588 6589 body.light-mode .hacker-card:hover { 6590 color: var(--text); 6591 box-shadow: none; 6592 } 6593 6594 body.light-mode .hacker-card-title { 6595 color: var(--text); 6596 } 6597 6598 body.light-mode .hacker-card-body { 6599 color: var(--text); 6600 } 6601 6602 body.light-mode .hacker-card-body code { 6603 background: rgba(0, 0, 0, 0.06); 6604 color: var(--dim); 6605 } 6606 6607 body.light-mode .hacker-card a { 6608 color: var(--pink); 6609 } 6610 6611 body.light-mode .at-gray { 6612 color: var(--dim); 6613 } 6614 6615 /* Light mode: AT handle scramble animation - gray instead of white */ 6616 body.light-mode .at-handle-prefix .at-char-gray { 6617 animation: atCharGrayLight 1.8s steps(1) infinite; 6618 } 6619 6620 @keyframes atCharGrayLight { 6621 0% { color: rgba(0, 0, 0, 0.35); } 6622 16.6% { color: rgba(0, 0, 0, 0.5); } 6623 33.3% { color: rgba(0, 0, 0, 0.4); } 6624 50% { color: rgba(0, 0, 0, 0.55); } 6625 66.6% { color: rgba(0, 0, 0, 0.45); } 6626 83.3% { color: rgba(0, 0, 0, 0.35); } 6627 100% { color: rgba(0, 0, 0, 0.35); } 6628 } 6629 6630 body.light-mode .hacker-section { 6631 box-shadow: none; 6632 } 6633 6634 body.light-mode .at-table td:first-child { 6635 color: var(--dim); 6636 } 6637 6638 body.light-mode .at-table td:last-child { 6639 color: var(--text); 6640 } 6641 6642 /* Light mode: Back to top / pals link */ 6643 body.light-mode #pals { 6644 color: var(--dim); 6645 } 6646 6647 body.light-mode #pals:hover { 6648 color: var(--text); 6649 } 6650 6651 /* Light mode: Link blocks */ 6652 body.light-mode .link-block { 6653 background: white; 6654 border-color: var(--box-border); 6655 } 6656 6657 body.light-mode .link-block a { 6658 color: var(--cyan); 6659 } 6660 6661 body.light-mode .link-block .link-desc { 6662 color: var(--dim); 6663 } 6664 6665 /* Light mode: Feature modules */ 6666 body.light-mode .feature-module { 6667 background: white; 6668 border-color: var(--box-border); 6669 box-shadow: var(--shadow-soft); 6670 } 6671 6672 body.light-mode .feature-module li { 6673 color: var(--text); 6674 } 6675 6676 /* Light mode: Apps flip card faces */ 6677 body.light-mode .apps-flip-face .module-caption { 6678 background: rgba(255, 255, 255, 0.95); 6679 color: var(--text); 6680 } 6681 6682 /* Light mode: Shop module - thicker border */ 6683 body.light-mode .shop-module { 6684 border: 3px solid rgba(205, 92, 155, 0.6); 6685 box-shadow: var(--shadow-medium); 6686 } 6687 6688 /* Shop bottom caption - light background with black text, with bottom border */ 6689 body.light-mode .shop-section .module-caption { 6690 background: rgba(255, 255, 255, 0.95); 6691 color: rgba(0, 0, 0, 0.6); 6692 border-bottom: 3px solid rgba(205, 92, 155, 0.6); 6693 } 6694 6695 /* Shop title (top left "shop") - keep white with shadow */ 6696 body.light-mode .shop-title { 6697 color: #fff; 6698 text-shadow: 0 1px 2px rgba(0,0,0,0.6), 0 0 8px rgba(0,0,0,0.4); 6699 } 6700 6701 body.light-mode .shop-title:hover { 6702 color: var(--pink); 6703 } 6704 6705 /* Shop progress bar - light gray track with pink progress for light mode */ 6706 body.light-mode .shop-auto-progress { 6707 background: rgba(200, 200, 200, 0.3); 6708 } 6709 6710 body.light-mode .shop-auto-progress-bar { 6711 background: rgba(205, 92, 155, 0.7); 6712 } 6713 6714 /* Light mode: Tapes/TV section */ 6715 body.light-mode .tapes-section { 6716 background: white; 6717 border-color: var(--box-border); 6718 } 6719 6720 body.light-mode .tapes-section .module-caption { 6721 background: rgba(255, 255, 255, 0.95); 6722 color: var(--text); 6723 } 6724 6725 /* Light mode: TV section with light letterbox */ 6726 body.light-mode .tv-section { 6727 background: #e8e8e8; 6728 box-shadow: 6729 -2px 0 0 rgba(205, 92, 155, 0.5), 6730 2px 0 0 rgba(8, 145, 178, 0.5), 6731 0 4px 12px rgba(0, 0, 0, 0.15); 6732 } 6733 6734 body.light-mode .tv-section .module-caption { 6735 background: #e8e8e8; 6736 color: var(--text); 6737 } 6738 6739 body.light-mode .tv-player { 6740 background: #e8e8e8; 6741 } 6742 6743 /* Light mode: Floating gives - no background, just readable text */ 6744 body.light-mode .floating-give { 6745 background: transparent; 6746 border: none; 6747 color: var(--text); 6748 text-shadow: 0 1px 3px rgba(255, 255, 255, 0.9), 0 0 8px rgba(255, 255, 255, 0.7); 6749 box-shadow: none; 6750 } 6751 6752 body.light-mode .floating-give .amount { 6753 color: var(--green); 6754 } 6755 6756 body.light-mode .floating-give .note { 6757 color: var(--gold); 6758 } 6759 6760 /* Light mode: Scrollbar - ensure visible */ 6761 body.light-mode { 6762 scrollbar-width: auto; 6763 -ms-overflow-style: auto; 6764 } 6765 6766 body.light-mode::-webkit-scrollbar { 6767 display: block; 6768 width: 10px; 6769 } 6770 6771 body.light-mode::-webkit-scrollbar-track { 6772 background: var(--bg); 6773 } 6774 6775 body.light-mode::-webkit-scrollbar-thumb { 6776 background: rgba(0, 0, 0, 0.2); 6777 border-radius: 5px; 6778 } 6779 6780 body.light-mode::-webkit-scrollbar-thumb:hover { 6781 background: rgba(0, 0, 0, 0.35); 6782 } 6783 6784 /* Light mode: Input fields */ 6785 body.light-mode input[type="text"], 6786 body.light-mode input[type="email"], 6787 body.light-mode textarea { 6788 background: white; 6789 border-color: var(--box-border); 6790 color: var(--text); 6791 } 6792 6793 body.light-mode input[type="text"]:focus, 6794 body.light-mode input[type="email"]:focus, 6795 body.light-mode textarea:focus { 6796 border-color: var(--pink); 6797 outline: none; 6798 } 6799 6800 /* Light mode: Platform download buttons - black text */ 6801 body.light-mode .desktop-platform-btn .plat-name { 6802 color: #111; 6803 } 6804 6805 body.light-mode .desktop-platform-btn .plat-icon svg { 6806 fill: #111; 6807 } 6808 6809 body.light-mode .desktop-platform-btn .plat-arch { 6810 color: #555; 6811 } 6812 6813 body.light-mode .desktop-platform-btn:hover .plat-name { 6814 color: #000; 6815 } 6816 6817 /* Light mode: Ableton link - black text/icon */ 6818 body.light-mode .desktop-ableton-link .ableton-icon { 6819 color: #111; 6820 } 6821 6822 body.light-mode .desktop-ableton-link .ableton-label { 6823 color: #111; 6824 } 6825 6826 /* Light mode: Thanks section */ 6827 body.light-mode .thanks-section { 6828 background: var(--bg); 6829 } 6830 6831 body.light-mode .thanks-canvas { 6832 border-color: var(--pink); 6833 box-shadow: 6834 0 0 20px rgba(205, 92, 155, 0.3), 6835 0 0 40px rgba(205, 92, 155, 0.15), 6836 0 8px 32px rgba(0, 0, 0, 0.15); 6837 } 6838 6839 body.light-mode .thanks-canvas:hover { 6840 box-shadow: 6841 0 0 30px rgba(205, 92, 155, 0.5), 6842 0 0 60px rgba(205, 92, 155, 0.25), 6843 0 12px 48px rgba(0, 0, 0, 0.2); 6844 } 6845 6846 body.light-mode .thanks-message { 6847 color: var(--text); 6848 } 6849 6850 body.light-mode .thanks-amount { 6851 color: var(--green); 6852 } 6853 6854 body.light-mode .thanks-again { 6855 background: white; 6856 border-color: var(--box-border); 6857 color: var(--cyan); 6858 } 6859 6860 body.light-mode .thanks-again:hover { 6861 background: var(--cyan); 6862 color: white; 6863 } 6864 6865 body.light-mode .thanks-share { 6866 background: white; 6867 border: 1px solid var(--box-border); 6868 color: var(--text); 6869 } 6870 6871 body.light-mode .thanks-share:hover { 6872 background: #1da1f2; 6873 border-color: #1da1f2; 6874 color: white; 6875 } 6876 6877 /* Light mode: Respect user's system preference */ 6878 @media (prefers-color-scheme: light) { 6879 body { 6880 --bg: #f5f5f5; 6881 --text: #1a1a2e; 6882 --dim: #666; 6883 --pink: rgb(205, 92, 155); 6884 --cyan: #0891b2; 6885 --gold: #d97706; 6886 --green: #059669; 6887 --box-bg: rgba(0,0,0,0.03); 6888 --box-border: rgba(0,0,0,0.12); 6889 --shadow-soft: 0 2px 8px rgba(0,0,0,0.08); 6890 --shadow-medium: 0 4px 16px rgba(0,0,0,0.12); 6891 --input-bg: white; 6892 --slider-track: linear-gradient(90deg, #f87171, #fbbf24, #22d3ee, #f472b6, #60a5fa, #8b5cf6, #06b6d4); 6893 } 6894 } 6895 </style> 6896</head> 6897 6898<body> 6899 <div class="top-bar"> 6900 <div style="display: flex; align-items: center; gap: 8px;"> 6901 <div class="logo" onclick="window.location.href='https://aesthetic.computer'"><span class="logo-give">give</span><span class="logo-sep"></span><span class="logo-ac">Aesthetic<span class="logo-dot">.</span>Computer</span><span class="logo-year">'26</span></div> 6902 <div class="lang-selector" id="langSelector"> 6903 <span class="lang-flag fi fi-us" id="lang-flag"></span> 6904 <span class="lang-text" id="lang-text">English</span> 6905 <span class="lang-arrow"></span> 6906 <div class="lang-dropdown" id="lang-dropdown"> 6907 <div class="lang-option" data-lang="en" data-flag="us"><span class="fi fi-us"></span> English</div> 6908 <div class="lang-option" data-lang="da" data-flag="dk"><span class="fi fi-dk"></span> Dansk</div> 6909 <div class="lang-option" data-lang="de" data-flag="de"><span class="fi fi-de"></span> Deutsch</div> 6910 <div class="lang-option" data-lang="es" data-flag="es"><span class="fi fi-es"></span> Español</div> 6911 <div class="lang-option" data-lang="zh" data-flag="cn"><span class="fi fi-cn"></span> 中文</div> 6912 </div> 6913 </div> 6914 </div> 6915 <div class="currency-links" id="currSelector"> 6916 <span class="curr-link active" data-curr="usd"><span class="curr-flag">💵</span><span class="curr-text">USD</span></span> 6917 <span class="curr-link" data-curr="dkk"><span class="curr-flag">💶</span><span class="curr-text">DKK</span></span> 6918 <span class="curr-link" data-curr="crypto"><span class="curr-flag">🔗</span><span class="curr-text">CRYPTO</span></span> 6919 <span class="curr-link" data-curr="paypal"><span class="curr-flag">🅿️</span><span class="curr-text">PAYPAL</span></span> 6920 <span class="curr-link" data-curr="liberapay"><span class="curr-flag">🪙</span><span class="curr-text">LIBERAPAY</span></span> 6921 </div> 6922 </div> 6923 6924 <main> 6925 <div class="content-grid"> 6926 <!-- Thank You Section (shown when ?thanks=1) --> 6927 <div class="thanks-section"> 6928 <div class="thanks-canvas-wrap"> 6929 <canvas class="thanks-canvas" id="thanksCanvas" width="560" height="560"></canvas> 6930 </div> 6931 <p class="thanks-message" data-lang="en"> 6932 TY for keeping systems online and free to use! 6933 </p> 6934 <p class="thanks-message" data-lang="da"> 6935 Tak for at give <span class="thanks-amount" id="thanksAmountDa">$25</span> og holde Aesthetic.Computer online og gratis at bruge. 6936 </p> 6937 <p class="thanks-message" data-lang="de"> 6938 Danke für Ihre Spende von <span class="thanks-amount" id="thanksAmountDe">$25</span>. Damit bleibt Aesthetic.Computer online und kostenlos. 6939 </p> 6940 <p class="thanks-message" data-lang="es"> 6941 Gracias por donar <span class="thanks-amount" id="thanksAmountEs">$25</span> y mantener Aesthetic.Computer en línea y gratuito. 6942 </p> 6943 <p class="thanks-message" data-lang="zh"> 6944 感谢您捐赠<span class="thanks-amount" id="thanksAmountZh">$25</span>,让Aesthetic.Computer保持在线并免费使用。 6945 </p> 6946 <div class="thanks-actions"> 6947 <a href="./" class="thanks-again" data-lang="en">Give again?</a> 6948 <a href="./" class="thanks-again" data-lang="da">Giv igen?</a> 6949 <a href="./" class="thanks-again" data-lang="de">Nochmal spenden?</a> 6950 <a href="./" class="thanks-again" data-lang="es">¿Donar de nuevo?</a> 6951 <a href="./" class="thanks-again" data-lang="zh">再次捐赠?</a> 6952 <button class="thanks-share" id="shareBtn" data-lang="en">Share</button> 6953 <button class="thanks-share" id="shareBtnDa" data-lang="da">Del</button> 6954 <button class="thanks-share" id="shareBtnDe" data-lang="de">Teilen</button> 6955 <button class="thanks-share" id="shareBtnEs" data-lang="es">Compartir</button> 6956 <button class="thanks-share" id="shareBtnZh" data-lang="zh">分享</button> 6957 </div> 6958 </div> 6959 6960 <!-- Gives Ticker - cycles through recent gives --> 6961 <div class="fiat-section full-width"> 6962 <div class="ticker-stats-row"> 6963 <div class="ticker-subscripts"> 6964 <div class="ticker-stat-badge gives-badge" id="giveStatsGives"> 6965 <span class="ticker-stat-value">0</span> 6966 <span class="ticker-stat-label">gives</span> 6967 <span class="ticker-stat-amount" id="giveStatsAmount"></span> 6968 </div> 6969 <div class="ticker-stat-badge monthly-badge" id="giveStatsSubs"> 6970 <span class="ticker-stat-value">0</span> 6971 <span class="ticker-stat-label">monthly</span> 6972 </div> 6973 </div> 6974 <div class="ticker-center"> 6975 <div class="gives-ticker" id="givesTicker"> 6976 <div class="gives-ticker-empty">Loading gives...</div> 6977 </div> 6978 </div> 6979 </div> 6980 <div class="currency-pickers"> 6981 <div class="currency-picker" data-for="usd"> 6982 <div class="gift-widget" data-currency="usd"> 6983 <div class="gift-visual"> 6984 <div class="jeffreys-slideshow" id="jeffreysSlideshow"></div> 6985 <div class="gift-logo-wrap"> 6986 <img src="https://aesthetic.computer/purple-pals.svg" class="gift-logo" alt=""> 6987 <div class="gift-amount">$128</div> 6988 </div> 6989 </div> 6990 <div class="gift-controls"> 6991 <input type="range" min="1" max="2048" step="1" value="128"> 6992 <button class="gift-btn" data-currency="usd">Give $128</button> 6993 <div class="invest-suggestion" style="display: none;"> 6994 <span data-lang="en">At this level, consider <a href="#invest" class="invest-link">investing</a> for equity!</span> 6995 <span data-lang="da">På dette niveau, overvej at <a href="#invest" class="invest-link">investere</a> for ejerandel!</span> 6996 <span data-lang="de">Bei diesem Betrag, erwägen Sie zu <a href="#invest" class="invest-link">investieren</a> für Eigenkapital!</span> 6997 <span data-lang="es">¡A este nivel, considere <a href="#invest" class="invest-link">invertir</a> para obtener participación!</span> 6998 <span data-lang="zh">在此金额下,考虑<a href="#invest" class="invest-link">投资</a>获取股权!</span> 6999 </div> 7000 <div class="gift-monthly-row"> 7001 <div class="gift-monthly-stack"> 7002 <label class="gift-monthly-check"> 7003 <input type="checkbox" class="monthly-checkbox"> 7004 <span data-lang="en">Monthly</span> 7005 <span data-lang="da">Månedligt</span> 7006 <span data-lang="de">Monatlich</span> 7007 <span data-lang="es">Mensual</span> 7008 <span data-lang="zh">每月</span> 7009 </label> 7010 <a href="#" class="cancel-subscription-link"> 7011 <span data-lang="en">Cancel?</span> 7012 <span data-lang="da">Annuller?</span> 7013 <span data-lang="de">Kündigen?</span> 7014 <span data-lang="es">¿Cancelar?</span> 7015 <span data-lang="zh">取消?</span> 7016 </a> 7017 </div> 7018 <span class="tax-note"> 7019 <span data-lang="en">Not tax deductible. <a href="#invest" class="invest-link">Investor?</a></span> 7020 <span data-lang="da">Ikke fradragsberettiget. <a href="#invest" class="invest-link">Investor?</a></span> 7021 <span data-lang="de">Nicht steuerlich absetzbar. <a href="#invest" class="invest-link">Investor?</a></span> 7022 <span data-lang="es">No deducible de impuestos. <a href="#invest" class="invest-link">¿Inversor?</a></span> 7023 <span data-lang="zh">不可抵税。<a href="#invest" class="invest-link">投资者?</a></span> 7024 </span> 7025 </div> 7026 </div> 7027 </div> 7028 </div> 7029 <div class="currency-picker" data-for="dkk" style="display: none;"> 7030 <div class="gift-widget" data-currency="dkk"> 7031 <div class="gift-visual"> 7032 <div class="gift-logo-wrap"> 7033 <img src="https://aesthetic.computer/purple-pals.svg" class="gift-logo" alt=""> 7034 <div class="gift-amount">512 kr</div> 7035 <div class="gift-conversion" id="dkkConversion"></div> 7036 </div> 7037 </div> 7038 <div class="gift-controls"> 7039 <input type="range" min="1" max="17500" step="1" value="512"> 7040 <button class="gift-btn" data-currency="dkk">Giv 512 kr</button> 7041 <div class="gift-monthly-row"> 7042 <div class="gift-monthly-stack"> 7043 <label class="gift-monthly-check"> 7044 <input type="checkbox" class="monthly-checkbox"> 7045 <span data-lang="en">Monthly</span> 7046 <span data-lang="da">Månedligt</span> 7047 <span data-lang="de">Monatlich</span> 7048 <span data-lang="es">Mensual</span> 7049 <span data-lang="zh">每月</span> 7050 </label> 7051 <a href="#" class="cancel-subscription-link"> 7052 <span data-lang="en">Cancel?</span> 7053 <span data-lang="da">Annuller?</span> 7054 <span data-lang="de">Kündigen?</span> 7055 <span data-lang="es">¿Cancelar?</span> 7056 <span data-lang="zh">取消?</span> 7057 </a> 7058 </div> 7059 <span class="tax-note"> 7060 <span data-lang="en">Not tax deductible. <a href="#invest" class="invest-link">Investor?</a></span> 7061 <span data-lang="da">Ikke fradragsberettiget. <a href="#invest" class="invest-link">Investor?</a></span> 7062 <span data-lang="de">Nicht steuerlich absetzbar. <a href="#invest" class="invest-link">Investor?</a></span> 7063 <span data-lang="es">No deducible de impuestos. <a href="#invest" class="invest-link">¿Inversor?</a></span> 7064 <span data-lang="zh">不可抵税。<a href="#invest" class="invest-link">投资者?</a></span> 7065 </span> 7066 </div> 7067 </div> 7068 </div> 7069 </div> 7070 <div class="currency-picker" data-for="crypto" style="display: none;"> 7071 <div class="gift-widget crypto crypto-compact" data-currency="crypto"> 7072 <div class="crypto-header"> 7073 <img src="https://aesthetic.computer/purple-pals.svg" class="crypto-logo-tiny" alt=""> 7074 <span class="crypto-title">🔗 CRYPTO</span> 7075 <span class="crypto-subtitle">🐷 Piggy Banks for Rainy Days</span> 7076 </div> 7077 <div class="crypto-controls crypto-all"> 7078 <div class="crypto-address copyable" data-copy="tz1gkf8EexComFBJvjtT1zdsisdah791KwBE" data-crypto="xtz"> 7079 <span class="crypto-label"><span class="crypto-symbol"><img src="https://raw.githubusercontent.com/ErikThiart/cryptocurrency-icons/master/64/tezos.png" alt="XTZ" class="crypto-icon"></span><span class="crypto-name">aesthetic.tez</span></span> 7080 <span class="crypto-balance" id="balance-xtz"><span class="loading">loading...</span></span> 7081 <code class="crypto-addr">tz1gkf8E<span class="addr-dots">···</span>1KwBE</code> 7082 <span class="crypto-copy-hint">→ copy</span> 7083 </div> 7084 <div class="crypto-address copyable" data-copy="0x1D64E3eFa983D945cBFe29Ad5b3C8ABB53Aef023" data-crypto="eth"> 7085 <span class="crypto-label"><span class="crypto-symbol"><img src="https://raw.githubusercontent.com/ErikThiart/cryptocurrency-icons/master/64/ethereum.png" alt="ETH" class="crypto-icon"></span><span class="crypto-name">4esthetic.eth</span></span> 7086 <span class="crypto-balance" id="balance-eth"><span class="loading">loading...</span></span> 7087 <code class="crypto-addr">0x1D64E3<span class="addr-dots">···</span>ef023</code> 7088 <span class="crypto-copy-hint">→ copy</span> 7089 </div> 7090 <div class="crypto-address copyable" data-copy="bc1q699gnqr92gvgv62nla82typpj3wls5a8xf59as" data-crypto="btc"> 7091 <span class="crypto-label"><span class="crypto-symbol"><img src="https://raw.githubusercontent.com/ErikThiart/cryptocurrency-icons/master/64/bitcoin.png" alt="BTC" class="crypto-icon"></span><span class="crypto-name">bitcoin</span></span> 7092 <span class="crypto-balance" id="balance-btc"><span class="loading">loading...</span></span> 7093 <code class="crypto-addr">bc1q699g<span class="addr-dots">···</span>f59as</code> 7094 <span class="crypto-copy-hint">→ copy</span> 7095 </div> 7096 </div> 7097 </div> 7098 </div> 7099 <div class="currency-picker" data-for="paypal" style="display: none;"> 7100 <div class="gift-widget paypal paypal-compact" data-currency="paypal"> 7101 <div class="paypal-header"> 7102 <img src="https://aesthetic.computer/purple-pals.svg" class="paypal-logo-tiny" alt=""> 7103 <span class="paypal-title">🅿️ PAYPAL</span> 7104 <span class="paypal-subtitle">Pay Aesthetic.Computer on PayPal</span> 7105 </div> 7106 <div class="paypal-controls"> 7107 <div class="paypal-qr-wrap"> 7108 <img src="https://assets.aesthetic.computer/images/paypal-qrcode.png" alt="PayPal QR Code" class="paypal-qr"> 7109 </div> 7110 <div class="paypal-actions"> 7111 <a href="https://www.paypal.com/qrcodes/managed/93f544e1-c016-4747-a63c-b1ee2ead0292?utm_source=payandgetpaid" target="_blank" rel="noopener" class="paypal-link-btn"> 7112 <span class="paypal-btn-text"> 7113 <span data-lang="en">Give via PayPal</span> 7114 <span data-lang="da">Giv via PayPal</span> 7115 <span data-lang="de">Geben via PayPal</span> 7116 <span data-lang="es">Dar via PayPal</span> 7117 <span data-lang="zh">通过PayPal捐赠</span> 7118 </span> 7119 </a> 7120 <div class="paypal-email copyable" data-copy="mail@aesthetic.computer"> 7121 <span class="paypal-email-label"> 7122 <span data-lang="en">or:</span> 7123 <span data-lang="da">eller:</span> 7124 <span data-lang="de">oder:</span> 7125 <span data-lang="es">o:</span> 7126 <span data-lang="zh">或:</span> 7127 </span> 7128 <code class="paypal-email-addr">mail@aesthetic.computer</code> 7129 <span class="paypal-copy-hint">→ copy</span> 7130 </div> 7131 </div> 7132 </div> 7133 </div> 7134 </div> 7135 <div class="currency-picker" data-for="liberapay" style="display: none;"> 7136 <div class="gift-widget liberapay liberapay-compact" data-currency="liberapay"> 7137 <div class="liberapay-header"> 7138 <img src="https://aesthetic.computer/purple-pals.svg" class="liberapay-logo-tiny" alt=""> 7139 <span class="liberapay-title">🪙 LIBERAPAY</span> 7140 <span class="liberapay-subtitle">Recurring donations on Liberapay</span> 7141 </div> 7142 <div class="liberapay-controls"> 7143 <a href="https://liberapay.com/aesthetic.computer/donate" target="_blank" rel="noopener" class="liberapay-link-btn"> 7144 <span data-lang="en">Give on Liberapay</span> 7145 <span data-lang="da">Giv via Liberapay</span> 7146 <span data-lang="de">Geben via Liberapay</span> 7147 <span data-lang="es">Dar via Liberapay</span> 7148 <span data-lang="zh">通过Liberapay捐赠</span> 7149 </a> 7150 <span class="liberapay-desc"> 7151 <span data-lang="en">open source platform for recurring donations — no fees taken by Liberapay</span> 7152 <span data-lang="da">open source platform for tilbagevendende donationer — ingen gebyrer fra Liberapay</span> 7153 <span data-lang="de">Open-Source-Plattform für wiederkehrende Spenden — keine Gebühren von Liberapay</span> 7154 <span data-lang="es">plataforma de código abierto para donaciones recurrentes — sin comisiones de Liberapay</span> 7155 <span data-lang="zh">开源定期捐赠平台 — Liberapay不收取任何费用</span> 7156 </span> 7157 </div> 7158 </div> 7159 </div> 7160 </div> 7161 </div> 7162 7163 <div class="prose full-width" data-lang="en"> 7164 <a href="https://aesthetic.computer">Aesthetic<span style="color: var(--pink);">.</span>Computer</a> is a social software network supported by the full-time programming practice of <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a> who has authored new media interfaces for 15 years. He lives in Echo Park with <a href="https://aesthetic.computer/@fifi" class="handle-link">@fifi</a> in a house full of musicians and regularly does show and tell at the <a href="https://nelacomputer.club" target="_blank">NELA Computer Club</a> in Chinatown. 7165 </div> 7166 <div class="prose full-width" data-lang="da"> 7167 <a href="https://aesthetic.computer">Aesthetic<span style="color: var(--pink);">.</span>Computer</a> er et socialt softwarenetværk understøttet af fuldtids ingeniørpraksis af <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a>, som har bygget eksperimentelle software-instrumenter i over et årti. Han bor i Echo Park med <a href="https://aesthetic.computer/@fifi" class="handle-link">@fifi</a> i et hus fuldt af musikere, og demonstrerer regelmæssigt softwaren ved <a href="https://nelacomputer.club" target="_blank">NELA Computer Club</a> i Chinatown. 7168 </div> 7169 <div class="prose full-width" data-lang="de"> 7170 <a href="https://aesthetic.computer">Aesthetic<span style="color: var(--pink);">.</span>Computer</a> ist ein soziales Software-Netzwerk, unterstützt durch die Vollzeit-Ingenieurpraxis von <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a>, der seit über einem Jahrzehnt experimentelle Software-Instrumente baut. Er lebt in Echo Park mit <a href="https://aesthetic.computer/@fifi" class="handle-link">@fifi</a> in einem Haus voller Musiker und präsentiert regelmäßig beim <a href="https://nelacomputer.club" target="_blank">NELA Computer Club</a> in Chinatown. 7171 </div> 7172 <div class="prose full-width" data-lang="es"> 7173 <a href="https://aesthetic.computer">Aesthetic<span style="color: var(--pink);">.</span>Computer</a> es una red de software social apoyada por la práctica de ingeniería a tiempo completo de <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a>, quien ha estado creando instrumentos de software experimentales durante más de una década. Vive en Echo Park con <a href="https://aesthetic.computer/@fifi" class="handle-link">@fifi</a> en una casa llena de músicos, y regularmente hace demostraciones en el <a href="https://nelacomputer.club" target="_blank">NELA Computer Club</a> en Chinatown. 7174 </div> 7175 <div class="prose full-width" data-lang="zh"> 7176 <a href="https://aesthetic.computer">Aesthetic<span style="color: var(--pink);">.</span>Computer</a> 是一个社交软件网络,由 <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a> 的全职工程实践所支持,他已经创建实验性软件工具超过十年。他与 <a href="https://aesthetic.computer/@fifi" class="handle-link">@fifi</a> 住在 Echo Park 一个满是音乐人的房子里,并定期在 Chinatown 的 <a href="https://nelacomputer.club" target="_blank">NELA Computer Club</a> 展示。 7177 </div> 7178 7179 <div class="prose full-width" data-lang="da"> 7180 🇩🇰 <a href="https://en.wikipedia.org/wiki/Goodiepal" target="_blank"><strong>Goodiepal</strong></a> og <a href="https://prompt.ac/laer-klokken" target="_blank" class="handle-link">laer-klokken</a>-fællesskabet har brugt softwaren hver dag til at dyrke fællesskab i Københavns IRL musik-, kunst- og kulturscene. 7181 </div> 7182 7183 <div class="why-give-section full-width"> 7184 <div class="why-give-title" data-lang="en">Why give?</div> 7185 <div class="why-give-title" data-lang="da">Hvorfor give?</div> 7186 <div class="why-give-title" data-lang="de">Warum geben?</div> 7187 <div class="why-give-title" data-lang="es">¿Por qué dar?</div> 7188 <div class="why-give-title" data-lang="zh">为什么捐赠?</div> 7189 7190 <div data-lang="en">Because we publish transparent costs. See <a class="why-link" href="https://bills.aesthetic.computer" target="_blank">Bills</a> to track where support goes.</div> 7191 <div data-lang="da">Fordi vi viser gennemsigtige omkostninger. Se <a class="why-link" href="https://bills.aesthetic.computer" target="_blank">Bills</a> for at følge hvor støtten går hen.</div> 7192 <div data-lang="de">Weil wir unsere Kosten transparent veröffentlichen. Unter <a class="why-link" href="https://bills.aesthetic.computer" target="_blank">Bills</a> sehen Sie, wohin die Unterstützung geht.</div> 7193 <div data-lang="es">Porque publicamos costos transparentes. En <a class="why-link" href="https://bills.aesthetic.computer" target="_blank">Bills</a> puedes ver a dónde va el apoyo.</div> 7194 <div data-lang="zh">因为我们公开透明地展示成本。查看 <a class="why-link" href="https://bills.aesthetic.computer" target="_blank">Bills</a>,了解支持资金的去向。</div> 7195 </div> 7196 7197 <div class="stats-section"> 7198 <div class="stat-item" id="stat-handles"> 7199 <div class="stat-floaters"></div> 7200 <div class="stat-value"></div> 7201 <div class="stat-label"> 7202 <span data-lang="en"><span class="stat-sigil">@</span>handles</span> 7203 <span data-lang="da"><span class="stat-sigil">@</span>brugernavne</span> 7204 <span data-lang="de"><span class="stat-sigil">@</span>Benutzer</span> 7205 <span data-lang="es"><span class="stat-sigil">@</span>usuarios</span> 7206 <span data-lang="zh"><span class="stat-sigil">@</span>用户名</span> 7207 </div> 7208 </div> 7209 <div class="stat-item" id="stat-paintings"> 7210 <div class="stat-floaters"></div> 7211 <div class="stat-value"></div> 7212 <div class="stat-label"> 7213 <span data-lang="en"><span class="stat-sigil">#</span>paintings</span> 7214 <span data-lang="da"><span class="stat-sigil">#</span>malerier</span> 7215 <span data-lang="de"><span class="stat-sigil">#</span>Bilder</span> 7216 <span data-lang="es"><span class="stat-sigil">#</span>pinturas</span> 7217 <span data-lang="zh"><span class="stat-sigil">#</span>绘画</span> 7218 </div> 7219 </div> 7220 <div class="stat-item" id="stat-moods"> 7221 <div class="stat-floaters"></div> 7222 <div class="stat-value"></div> 7223 <div class="stat-label"> 7224 <span data-lang="en">moods</span> 7225 <span data-lang="da">stemninger</span> 7226 <span data-lang="de">Stimmungen</span> 7227 <span data-lang="es">estados</span> 7228 <span data-lang="zh">心情</span> 7229 </div> 7230 </div> 7231 <div class="stat-item" id="stat-kidlisp"> 7232 <div class="stat-floaters"></div> 7233 <div class="stat-value"></div> 7234 <div class="stat-label"> 7235 <span data-lang="en"><span class="stat-sigil">$</span>kidlisps</span> 7236 <span data-lang="da"><span class="stat-sigil">$</span>kidlisps</span> 7237 <span data-lang="de"><span class="stat-sigil">$</span>kidlisps</span> 7238 <span data-lang="es"><span class="stat-sigil">$</span>kidlisps</span> 7239 <span data-lang="zh"><span class="stat-sigil">$</span>kidlisps</span> 7240 </div> 7241 </div> 7242 <div class="stat-item" id="stat-commands"> 7243 <div class="stat-floaters"></div> 7244 <div class="stat-value"></div> 7245 <div class="stat-label"> 7246 <span data-lang="en">commands</span> 7247 <span data-lang="da">kommandoer</span> 7248 <span data-lang="de">Befehle</span> 7249 <span data-lang="es">comandos</span> 7250 <span data-lang="zh">命令</span> 7251 </div> 7252 </div> 7253 <div class="stat-item" id="stat-messages"> 7254 <div class="stat-floaters"></div> 7255 <div class="stat-value"></div> 7256 <div class="stat-label"> 7257 <span data-lang="en">chats</span> 7258 <span data-lang="da">chats</span> 7259 <span data-lang="de">Chats</span> 7260 <span data-lang="es">chats</span> 7261 <span data-lang="zh">聊天</span> 7262 </div> 7263 </div> 7264 </div> 7265 7266 <div class="kidlisp-preview"> 7267 <a href="https://kidlisp.com" class="kidlisp-preview-header" id="kidlispHeaderLink" target="_blank"> 7268 <div class="kidlisp-header-code-wrap"> 7269 <strong class="kidlisp-logo" style="font-family: 'Comic Relief', 'Comic Sans MS', cursive; font-size: 1.1em;"><span style="color:#FF6B6B">K</span><span style="color:#4ECDC4">i</span><span style="color:#FFE66D">d</span><span style="color:#95E1D3">L</span><span style="color:#F38181">i</span><span style="color:#AA96DA">s</span><span style="color:#70D6FF">p</span><span style="color:var(--dim)">.</span><span style="color:#FF6B6B">c</span><span style="color:#9370DB">o</span><span style="color:#90EE90">m</span></strong> 7270 <span class="kidlisp-header-sep">/</span> 7271 <span class="kidlisp-header-code" id="kidlispHeaderCode"></span> 7272 </div> 7273 <span class="kidlisp-play-indicator" title="Open Editor"></span> 7274 </a> 7275 <div class="kidlisp-carousel" id="kidlispCarousel"> 7276 <div class="module-loading" id="kidlispLoading">loading</div> 7277 </div> 7278 <!-- Auto-advance progress bar --> 7279 <div class="kidlisp-auto-progress"> 7280 <div class="kidlisp-auto-progress-bar" id="kidlispAutoProgress"></div> 7281 </div> 7282 <div class="module-caption"> 7283 <span data-lang="en">A new programming language for gen beta</span> 7284 <span data-lang="da">Et nyt programmeringssprog for gen beta</span> 7285 <span data-lang="de">Eine neue Programmiersprache für Gen Beta</span> 7286 <span data-lang="es">Un nuevo lenguaje de programación para gen beta</span> 7287 <span data-lang="zh">为gen beta设计的新编程语言</span> 7288 </div> 7289 </div> 7290 7291 <div class="chat-flip-container"> 7292 <div class="chat-flip-card" id="chatFlipCard"> 7293 <!-- Front: laer-klokken (active when not flipped) --> 7294 <div class="chat-flip-face chat-flip-front chat-preview"> 7295 <div class="chat-preview-header chat-flip-trigger" onclick="toggleChatFlip()"> 7296 <strong>🕐 laer-klokken</strong> 7297 </div> 7298 <div class="chat-messages" id="chatMessages"> 7299 <div class="chat-msg"><div class="chat-text" style="color: var(--dim);">Loading...</div></div> 7300 </div> 7301 <div class="module-caption chat-flip-trigger" onclick="toggleChatFlip()"> 7302 <span data-lang="en"><a href="https://aesthetic.computer/@prutti" target="_blank" style="color: var(--pink);">@prutti</a>'s weekly <a href="https://prompt.ac/r8dio" target="_blank" style="color: var(--pink);">r8Dio</a> show LIVE from CPH</span> 7303 <span data-lang="da"><a href="https://aesthetic.computer/@prutti" target="_blank" style="color: var(--pink);">@prutti</a>s ugentlige <a href="https://prompt.ac/r8dio" target="_blank" style="color: var(--pink);">r8Dio</a> show LIVE fra KBH</span> 7304 <span data-lang="de"><a href="https://aesthetic.computer/@prutti" target="_blank" style="color: var(--pink);">@prutti</a>s wöchentliche <a href="https://prompt.ac/r8dio" target="_blank" style="color: var(--pink);">r8Dio</a> Show LIVE aus KPH</span> 7305 <span data-lang="es"><a href="https://aesthetic.computer/@prutti" target="_blank" style="color: var(--pink);">@prutti</a> show semanal de <a href="https://prompt.ac/r8dio" target="_blank" style="color: var(--pink);">r8Dio</a> EN VIVO desde CPH</span> 7306 <span data-lang="zh"><a href="https://aesthetic.computer/@prutti" target="_blank" style="color: var(--pink);">@prutti</a>的每周<a href="https://prompt.ac/r8dio" target="_blank" style="color: var(--pink);">r8Dio</a>节目从哥本哈根直播</span> 7307 </div> 7308 <div class="flip-face-progress"> 7309 <div class="flip-face-progress-bar" id="chatFlipProgress"></div> 7310 </div> 7311 </div> 7312 7313 <!-- Back: chat-system (ghost overlay when not flipped, active when flipped) --> 7314 <div class="chat-flip-face chat-flip-back chat-preview-system"> 7315 <div class="chat-system-header chat-flip-trigger" onclick="toggleChatFlip()"> 7316 <strong>💬 chat</strong> 7317 </div> 7318 <div class="chat-messages" id="chatSystemMessages"> 7319 <div class="chat-msg"><div class="chat-text" style="color: var(--dim);">Loading...</div></div> 7320 </div> 7321 <div class="module-caption chat-flip-trigger" onclick="toggleChatFlip()"> 7322 <span data-lang="en">Get a <a href="https://prompt.ac/get-handle" target="_blank" style="color: var(--pink);">@handle</a> and come say hi</span> 7323 <span data-lang="da">Få en <a href="https://prompt.ac/get-handle" target="_blank" style="color: var(--pink);">@handle</a> og kom og sig hej</span> 7324 <span data-lang="de">Hol dir einen <a href="https://prompt.ac/get-handle" target="_blank" style="color: var(--pink);">@handle</a> und sag Hallo</span> 7325 <span data-lang="es">Obtén un <a href="https://prompt.ac/get-handle" target="_blank" style="color: var(--pink);">@handle</a> y ven a saludar</span> 7326 <span data-lang="zh">获取<a href="https://prompt.ac/get-handle" target="_blank" style="color: var(--pink);">@handle</a>来打个招呼吧</span> 7327 </div> 7328 <div class="flip-face-progress flip-face-progress-back"> 7329 <div class="flip-face-progress-bar" id="chatFlipProgressBack"></div> 7330 </div> 7331 </div> 7332 </div> 7333 </div> 7334 7335 <!-- WIP: notepat module 7336 <div class="link-block notepat-section"> 7337 <strong>🎹 notepat</strong> 7338 <a href="https://aesthetic.computer/notepat" target="_blank">aesthetic.computer/notepat</a> 7339 <span class="link-desc" data-lang="en">Musical keyboard</span> 7340 <span class="link-desc" data-lang="da">Musikkeyboard</span> 7341 </div> 7342 --> 7343 7344 <div class="tv-section-wrap"> 7345 <div class="tv-backdrop-glow" id="tvBackdropGlow"></div> 7346 <div class="tv-section"> 7347 <div class="tv-header" id="tvHeader"> 7348 <strong>📼 tapes</strong> 7349 </div> 7350 <div class="tv-player" id="tvPlayer"> 7351 <div class="module-loading" id="tvLoading">loading</div> 7352 </div> 7353 <div class="module-caption"> 7354 <span data-lang="en">Prefix a prompt with <span class="cmd-highlight">tape</span> to record it</span> 7355 <span data-lang="da">Sæt <span class="cmd-highlight">tape</span> før en prompt for at optage</span> 7356 <span data-lang="de">Setze <span class="cmd-highlight">tape</span> vor einen Prompt zum Aufnehmen</span> 7357 <span data-lang="es">Prefija un prompt con <span class="cmd-highlight">tape</span> para grabarlo</span> 7358 <span data-lang="zh">在提示前加 <span class="cmd-highlight">tape</span> 即可录制</span> 7359 </div> 7360 </div> 7361 </div> 7362 7363 <!-- Apps Flip: Desktop / Mobile --> 7364 <div class="apps-flip-container" id="appsFlipContainer"> 7365 <div class="apps-flip-card" id="appsFlipCard"> 7366 <!-- Front: Desktop (active when not flipped) --> 7367 <div class="apps-flip-face apps-flip-front desktop-face"> 7368 <div class="desktop-face-header" onclick="toggleAppsFlip()"> 7369 <span class="face-label"> 7370 <span data-lang="en">desktop</span> 7371 <span data-lang="da">skrivebord</span> 7372 <span data-lang="de">Desktop</span> 7373 <span data-lang="es">escritorio</span> 7374 <span data-lang="zh">桌面</span> 7375 </span> 7376 <div class="desktop-header-ticker"> 7377 <div class="desktop-header-ticker-content" id="changelogTicker"> 7378 <span>Loading changelog...</span> 7379 </div> 7380 </div> 7381 </div> 7382 7383 <div class="desktop-platforms-grid"> 7384 <a href="https://github.com/whistlegraph/aesthetic-computer/releases/download/v0.1.8/Aesthetic-Computer-0.1.8-universal.dmg" class="desktop-platform-btn plat-mac" title="Download for macOS" target="_blank"> 7385 <span class="plat-icon"><svg viewBox="0 0 384 512" xmlns="http://www.w3.org/2000/svg"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg></span> 7386 <span class="plat-name">Mac</span> 7387 <span class="plat-arch" id="macVersion">v0.1.8</span> 7388 </a> 7389 <a href="https://github.com/whistlegraph/aesthetic-computer/releases/download/v0.1.8/Aesthetic-Computer-Setup-0.1.8.exe" class="desktop-platform-btn plat-win" title="Download for Windows" target="_blank"> 7390 <span class="plat-icon"><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#0078D4" d="M0 3.5L10 2.2V11.5H0V3.5ZM11 2L24 0V11.5H11V2ZM0 12.5H10V21.8L0 20.5V12.5ZM11 12.5H24V24L11 22V12.5Z"/></svg></span> 7391 <span class="plat-name">Windows</span> 7392 <span class="plat-arch" id="winVersion">v0.1.8</span> 7393 </a> 7394 <a href="https://github.com/whistlegraph/aesthetic-computer/releases/download/v0.1.8/Aesthetic-Computer-0.1.8.AppImage" class="desktop-platform-btn plat-linux" title="Download for Linux" target="_blank"> 7395 <span class="plat-icon"><svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><ellipse cx="50" cy="70" rx="30" ry="25" fill="#F5C400"/><ellipse cx="50" cy="45" rx="25" ry="28" fill="#333"/><ellipse cx="50" cy="50" rx="18" ry="20" fill="#fff"/><circle cx="42" cy="38" r="4" fill="#333"/><circle cx="58" cy="38" r="4" fill="#333"/><circle cx="43" cy="37" r="1.5" fill="#fff"/><circle cx="59" cy="37" r="1.5" fill="#fff"/><ellipse cx="50" cy="48" rx="4" ry="3" fill="#F5A000"/><ellipse cx="35" cy="85" rx="10" ry="6" fill="#F5A000"/><ellipse cx="65" cy="85" rx="10" ry="6" fill="#F5A000"/></svg></span> 7396 <span class="plat-name">Linux</span> 7397 <span class="plat-arch" id="linuxVersion">v0.1.8</span> 7398 </a> 7399 </div> 7400 7401 <div class="desktop-tools-row"> 7402 <a href="https://marketplace.visualstudio.com/items?itemName=aesthetic-computer.aesthetic-computer-code" class="desktop-vscode-link" target="_blank"> 7403 <img class="vscode-icon-img" id="vscodeIcon" src="https://aesthetic-computer.gallerycdn.vsassets.io/extensions/aesthetic-computer/aesthetic-computer-code/1.219.1/1767559081239/Microsoft.VisualStudio.Services.Icons.Default" alt="VS Code" onerror="this.style.display='none';this.nextElementSibling.style.display='inline'"> 7404 <span class="vscode-icon" style="display:none">💜</span> 7405 <span class="vscode-label">VS Code</span> 7406 <span class="vscode-ver" id="vscodeExtVersion">v1.217.0</span> 7407 </a> 7408 7409 <a href="https://assets.aesthetic.computer/ableton/notepat-ableton-2025-alpha.amxd.zip" class="desktop-ableton-link" target="_blank" download> 7410 <svg class="ableton-icon" viewBox="0 0 51 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M0 0h3v24H0V0zm6 0h3v24H6V0zm6 0h3v24h-3V0zm6 0h3v24h-3V0zm9 0h24v3H27V0zm0 7h24v3H27V7zm0 7h24v3H27v-3zm0 7h24v3H27v-3z"/></svg> 7411 <span class="ableton-label">Ableton</span> 7412 <span class="ableton-tag">notepat</span> 7413 </a> 7414 </div> 7415 <div class="module-caption apps-flip-trigger" onclick="toggleAppsFlip()"> 7416 <span data-lang="en">Desktop apps and plugins are free</span> 7417 <span data-lang="da">Desktop-apps og plugins er gratis</span> 7418 <span data-lang="de">Desktop-Apps und Plugins sind kostenlos</span> 7419 <span data-lang="es">Las apps de escritorio y plugins son gratis</span> 7420 <span data-lang="zh">桌面应用和插件免费</span> 7421 </div> 7422 <div class="flip-face-progress"> 7423 <div class="flip-face-progress-bar" id="appsFlipProgress"></div> 7424 </div> 7425 </div> 7426 7427 <!-- Back: Mobile (ghost overlay when not flipped, active when flipped) --> 7428 <div class="apps-flip-face apps-flip-back mobile-face"> 7429 <div class="mobile-face-header" onclick="toggleAppsFlip()"> 7430 <span class="face-label"> 7431 <span data-lang="en">mobile</span> 7432 <span data-lang="da">mobil</span> 7433 <span data-lang="de">Mobil</span> 7434 <span data-lang="es">móvil</span> 7435 <span data-lang="zh">移动</span> 7436 </span> 7437 </div> 7438 7439 <div class="mobile-face-content"> 7440 <img class="mobile-app-icon" id="iosAppIcon" 7441 src="https://is1-ssl.mzstatic.com/image/thumb/Purple126/v4/91/c2/9c/91c29cd6-ff12-a9ad-0a67-1d5700352b62/AppIcon-0-0-1x_U007epad-0-85-220.png/100x100bb.jpg" 7442 alt="Aesthetic Computer" 7443 onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"> 7444 <div class="mobile-app-icon-fallback" style="display:none">🎨</div> 7445 <div class="mobile-app-title">Aesthetic<span class="app-bounce-dot">.</span>Computer</div> 7446 7447 <a href="https://apps.apple.com/ag/app/aesthetic-computer/id6450940883" 7448 class="mobile-store-link" target="_blank"> 7449 <span data-lang="en">iOS App Store</span> 7450 <span data-lang="da">iOS App Store</span> 7451 <span data-lang="de">iOS App Store</span> 7452 <span data-lang="es">iOS App Store</span> 7453 <span data-lang="zh">iOS应用商店</span> 7454 </a> 7455 </div> 7456 <div class="module-caption apps-flip-trigger" onclick="toggleAppsFlip()"> 7457 <span data-lang="en">Mobile app is free on iOS</span> 7458 <span data-lang="da">Mobil-appen er gratis på iOS</span> 7459 <span data-lang="de">Die mobile App ist kostenlos auf iOS</span> 7460 <span data-lang="es">La app móvil es gratis en iOS</span> 7461 <span data-lang="zh">移动应用在iOS上免费</span> 7462 </div> 7463 <div class="flip-face-progress flip-face-progress-back"> 7464 <div class="flip-face-progress-bar" id="appsFlipProgressMobile"></div> 7465 </div> 7466 </div> 7467 </div> 7468 </div> 7469 7470 <div class="shop-section" id="shopSection" hidden> 7471 <div class="shop-header"> 7472 <a href="https://shop.aesthetic.computer" class="shop-title" target="_blank"> 7473 <span data-lang="en">shop</span> 7474 <span data-lang="da">butik</span> 7475 <span data-lang="de">Shop</span> 7476 <span data-lang="es">tienda</span> 7477 <span data-lang="zh">商店</span> 7478 </a> 7479 </div> 7480 <div class="shop-products" id="shopProducts"> 7481 <div class="module-loading" id="shopLoading">loading</div> 7482 </div> 7483 <!-- Auto-advance progress bar --> 7484 <div class="shop-auto-progress"> 7485 <div class="shop-auto-progress-bar" id="shopAutoProgress"></div> 7486 </div> 7487 <div class="module-caption"> 7488 <span data-lang="en">Get books, bikes, artworks and more</span> 7489 <span data-lang="da">Få bøger, cykler, kunstværker og mere</span> 7490 <span data-lang="de">Holen Sie sich Bücher, Fahrräder, Kunstwerke und mehr</span> 7491 <span data-lang="es">Obtén libros, bicicletas, obras de arte y más</span> 7492 <span data-lang="zh">获取书籍、自行车、艺术品等</span> 7493 </div> 7494 </div> 7495 7496 <div class="invest-section" id="invest"> 7497 <div class="invest-header"> 7498 <span>📈</span> 7499 <span data-lang="en">Invest</span> 7500 <span data-lang="da">Investér</span> 7501 <span data-lang="de">Investieren</span> 7502 <span data-lang="es">Invertir</span> 7503 <span data-lang="zh">投资</span> 7504 </div> 7505 <div class="invest-content" data-lang="en"> 7506 <p><a href="https://aesthetic.direct" target="_blank">Aesthetic Inc.</a> was <a href="https://prompt.ac/booted-by" target="_blank">booted by</a> a handful of early supporters in 2025.<br><br>To join this equity holding community or increase your existing stake, <a href="https://calendly.com/aesthetic-computer/demo" class="demo-link" target="_blank">book a remote demo</a> with <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a>.</p> 7507 </div> 7508 <div class="invest-content" data-lang="da"> 7509 <p><a href="https://aesthetic.direct" target="_blank">Aesthetic Inc.</a> bliver <a href="https://prompt.ac/booted-by" target="_blank">startet af</a> en håndfuld tidlige støtter, der deler i at reservere virksomhedskapital.<br><br>For at blive en del af dette fællesskab, <a href="https://calendly.com/aesthetic-computer/demo" class="demo-link" target="_blank">book en fjern-demo</a> med <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a>.</p> 7510 </div> 7511 <div class="invest-content" data-lang="de"> 7512 <p><a href="https://aesthetic.direct" target="_blank">Aesthetic Inc.</a> wurde von einer Handvoll früher Unterstützer <a href="https://prompt.ac/booted-by" target="_blank">gestartet</a>.<br><br>Um dieser Investoren-Community beizutreten, <a href="https://calendly.com/aesthetic-computer/demo" class="demo-link" target="_blank">buchen Sie eine Demo</a> mit <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a>.</p> 7513 </div> 7514 <div class="invest-content" data-lang="es"> 7515 <p><a href="https://aesthetic.direct" target="_blank">Aesthetic Inc.</a> fue <a href="https://prompt.ac/booted-by" target="_blank">iniciada por</a> un puñado de primeros partidarios.<br><br>Para unirte a esta comunidad de inversores, <a href="https://calendly.com/aesthetic-computer/demo" class="demo-link" target="_blank">reserva una demo remota</a> con <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a>.</p> 7516 </div> 7517 <div class="invest-content" data-lang="zh"> 7518 <p><a href="https://aesthetic.direct" target="_blank">Aesthetic Inc.</a> 由一小群早期支持者<a href="https://prompt.ac/booted-by" target="_blank">启动</a><br><br>要加入这个股权持有社区,请与 <a href="https://aesthetic.computer/@jeffrey" class="handle-link">@jeffrey</a> <a href="https://calendly.com/aesthetic-computer/demo" class="demo-link" target="_blank">预约远程演示</a></p> 7519 </div> 7520 <a href="https://aesthetic.computer/+" class="invest-mug" id="investMug" title="Buy a Mug" target="_blank"> 7521 <img class="invest-mug-image" id="investMugImage" alt="Mug preview" /> 7522 <span class="invest-mug-icon" id="investMugIcon"></span> 7523 <span class="invest-mug-caption" id="investMugCaption"><span class="mug-color">white</span><span class="mug-of"> mug of </span><span class="mug-code">CODE</span><span class="mug-via"> in $give</span></span> 7524 </a> 7525 </div> 7526 7527 <div class="page-footer"> 7528 <div class="footer-auth" id="footerAuth"> 7529 <div class="footer-auth-buttons" id="footerAuthButtons"> 7530 <button class="footer-login-btn" id="footerLoginBtn"> 7531 <span data-lang="en">Log in</span> 7532 <span data-lang="da">Log ind</span> 7533 <span data-lang="de">Anmelden</span> 7534 <span data-lang="es">Entrar</span> 7535 <span data-lang="zh">登录</span> 7536 </button> 7537 <button class="footer-signup-btn" id="footerSignupBtn"> 7538 <span data-lang="en">I'm new</span> 7539 <span data-lang="da">Jeg er ny</span> 7540 <span data-lang="de">Ich bin neu</span> 7541 <span data-lang="es">Soy nuevo</span> 7542 <span data-lang="zh">我是新用户</span> 7543 </button> 7544 </div> 7545 <div class="footer-user-stack" id="footerUserStack" style="display: none;"> 7546 <a class="footer-user-menu" id="footerUserMenu" href="#"> 7547 <span class="footer-user-handle" id="footerUserHandle"></span> 7548 </a> 7549 <a class="footer-logout-link" id="footerLogoutLink" href="#"> 7550 <span data-lang="en">logout?</span> 7551 <span data-lang="da">log ud?</span> 7552 <span data-lang="de">abmelden?</span> 7553 <span data-lang="es">¿salir?</span> 7554 <span data-lang="zh">登出?</span> 7555 </a> 7556 </div> 7557 </div> 7558 </div> 7559 7560 <!-- AT Protocol Federation Info --> 7561 <div class="hacker-section"> 7562 <div class="hacker-card"> 7563 <a href="https://at.aesthetic.computer" target="_blank" class="hacker-card-title"> 7564 <span class="at-line-1"><span class="at-handle-prefix" id="atHandlePrefix"></span><span class="ac-dot">.</span><span class="at-blue">at</span><span class="ac-dot">.</span><span class="ac-name">Aesthetic<span class="ac-dot">.</span>Computer</span> <span class="at-gray">is federated</span></span> 7565 <span class="at-line-2"><span class="at-gray">on the</span> <img src="https://assets.aesthetic.computer/atproto-icon.png" alt="" class="at-icon"><span class="at-blue">AT</span><span class="at-gray">Protocol</span></span> 7566 </a> 7567 </div> 7568 </div> 7569 7570 <a id="pals" onclick="window.scrollTo({top: 0, behavior: 'smooth'}); return false;" href="#"> 7571 <div class="pals-logo-container"> 7572 <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo"> 7573 <img src="https://aesthetic.computer/purple-pals.svg" alt="" class="pals-logo-pink"> 7574 </div> 7575 <span data-lang="en">↑ Back to Top</span> 7576 <span data-lang="da">↑ Tilbage til top</span> 7577 <span data-lang="de">↑ Nach oben</span> 7578 <span data-lang="es">↑ Volver arriba</span> 7579 <span data-lang="zh">↑ 返回顶部</span> 7580 </a> 7581 </div> 7582 </main> 7583 7584 <!-- Login prompt modal for monthly subscriptions (native dialog) --> 7585 <dialog class="login-modal" id="loginModal"> 7586 <div class="login-modal-title"> 7587 <span data-lang="en">Log in for Monthly</span> 7588 <span data-lang="da">Log ind for månedligt</span> 7589 <span data-lang="de">Anmelden für Monatlich</span> 7590 <span data-lang="es">Iniciar sesión para Mensual</span> 7591 <span data-lang="zh">登录以订阅</span> 7592 </div> 7593 <div class="login-modal-text"> 7594 <span data-lang="en">Log in first so we can link your subscription to your account.</span> 7595 <span data-lang="da">Log ind først, så vi kan tilknytte dit abonnement til din konto.</span> 7596 <span data-lang="de">Melden Sie sich an, damit wir Ihr Abonnement mit Ihrem Konto verknüpfen können.</span> 7597 <span data-lang="es">Inicia sesión primero para vincular tu suscripción a tu cuenta.</span> 7598 <span data-lang="zh">请先登录,以便将您的订阅关联到您的账户。</span> 7599 </div> 7600 <div class="login-modal-buttons"> 7601 <button class="login-modal-btn secondary" id="loginModalCancel"> 7602 <span data-lang="en">Not now</span> 7603 <span data-lang="da">Ikke nu</span> 7604 <span data-lang="de">Nicht jetzt</span> 7605 <span data-lang="es">Ahora no</span> 7606 <span data-lang="zh">暂不</span> 7607 </button> 7608 <button class="login-modal-btn primary" id="loginModalConfirm"> 7609 <span data-lang="en">Log in</span> 7610 <span data-lang="da">Log ind</span> 7611 <span data-lang="de">Anmelden</span> 7612 <span data-lang="es">Iniciar sesión</span> 7613 <span data-lang="zh">登录</span> 7614 </button> 7615 </div> 7616 </dialog> 7617 7618 <script> 7619 // ===== VS Code Detection & External Link Handling ===== 7620 // Check if running inside VS Code webview (sandboxed iframe) 7621 function isInVSCode() { 7622 const urlParams = new URLSearchParams(window.location.search); 7623 return urlParams.get('vscode') === 'true' || 7624 (window.acVSCODE && window.parent !== window); 7625 } 7626 7627 // Open URL externally - handles VS Code sandboxed iframe case 7628 function openExternal(url) { 7629 if (isInVSCode()) { 7630 // In VS Code extension webview, send message to parent to open externally 7631 window.parent.postMessage({ type: 'openExternal', url }, '*'); 7632 return true; 7633 } 7634 // Normal browser - open in new tab 7635 window.open(url, '_blank', 'noopener'); 7636 return true; 7637 } 7638 7639 // Intercept all target="_blank" links for VS Code compatibility 7640 document.addEventListener('click', (e) => { 7641 const link = e.target.closest('a[target="_blank"]'); 7642 if (link && isInVSCode()) { 7643 e.preventDefault(); 7644 openExternal(link.href); 7645 } 7646 }); 7647 7648 // Dev/prod URL detection for KidLisp.com links 7649 const isDev = window.location.hostname === 'localhost' || window.location.hostname.includes('local.'); 7650 const kidlispUrl = isDev ? 'https://localhost:8888/kidlisp.com' : 'https://kidlisp.com'; 7651 7652 // Update static kidlisp links to use dev URL if on localhost 7653 document.addEventListener('DOMContentLoaded', () => { 7654 const headerLink = document.getElementById('kidlispHeaderLink'); 7655 if (headerLink && isDev) { 7656 headerLink.href = kidlispUrl; 7657 } 7658 }); 7659 7660 // ====== Theme: System Default (Light/Dark Mode) ====== 7661 // Apply light-mode class based on system preference (CSS rules use body.light-mode) 7662 localStorage.removeItem('give-theme-preference'); // Clear any old stored preference 7663 7664 function applySystemTheme() { 7665 if (window.matchMedia('(prefers-color-scheme: light)').matches) { 7666 document.body.classList.add('light-mode'); 7667 document.body.classList.remove('dark-mode'); 7668 } else { 7669 document.body.classList.remove('light-mode'); 7670 document.body.classList.add('dark-mode'); 7671 } 7672 } 7673 7674 // Apply on load 7675 applySystemTheme(); 7676 7677 // Listen for system preference changes 7678 window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', applySystemTheme); 7679 7680 // ====== A/B/C/D/E Test: Starting Price ====== 7681 // Monthly options (60% total): D: $8/mo (30%), E: $2/mo (30%) 7682 // One-time options (40% total): A: $32 (13.3%), B: $64 (13.3%), C: $128 (13.3%) 7683 const abcTestRandom = Math.random(); 7684 const abcTestVariant = abcTestRandom < 0.3 ? 'E' : abcTestRandom < 0.6 ? 'D' : abcTestRandom < 0.733 ? 'A' : abcTestRandom < 0.866 ? 'B' : 'C'; 7685 let abcTestStartPrice = abcTestVariant === 'A' ? 32 : abcTestVariant === 'B' ? 64 : abcTestVariant === 'C' ? 128 : abcTestVariant === 'D' ? 8 : 2; 7686 let abcTestIsMonthly = abcTestVariant === 'D' || abcTestVariant === 'E'; 7687 7688 // Check for URL amount override (e.g., from kidlisp.com guides) 7689 const urlAmountParams = new URLSearchParams(window.location.search); 7690 const urlAmountOverride = urlAmountParams.get('amount'); 7691 if (urlAmountOverride) { 7692 const parsedAmount = parseInt(urlAmountOverride, 10); 7693 if (!isNaN(parsedAmount) && parsedAmount > 0 && parsedAmount <= 10000) { 7694 abcTestStartPrice = parsedAmount; 7695 abcTestIsMonthly = false; // URL override = one-time by default 7696 console.log(`💝 Amount override from URL: $${parsedAmount}`); 7697 } 7698 } 7699 7700 // Apply A/B/C test price to all USD widgets immediately 7701 document.querySelectorAll('.gift-widget[data-currency="usd"]').forEach(widget => { 7702 const amountEl = widget.querySelector('.gift-amount'); 7703 const slider = widget.querySelector('input[type="range"]'); 7704 const btn = widget.querySelector('.gift-btn'); 7705 const monthlyCheck = widget.querySelector('.monthly-checkbox'); 7706 const monthlyLabel = widget.querySelector('.gift-monthly-check'); 7707 7708 if (amountEl) amountEl.textContent = `$${abcTestStartPrice}`; 7709 if (slider) { 7710 // Adjust slider range for monthly mode 7711 if (abcTestIsMonthly) { 7712 slider.min = 1; 7713 slider.max = 500; 7714 } 7715 slider.value = abcTestStartPrice; 7716 } 7717 if (btn) btn.innerHTML = abcTestIsMonthly ? `Give <span class="price">$${abcTestStartPrice}/mo</span>` : `Give <span class="price">$${abcTestStartPrice}</span>`; 7718 7719 // For variant C, check the monthly box 7720 if (abcTestIsMonthly && monthlyCheck) { 7721 monthlyCheck.checked = true; 7722 if (monthlyLabel) monthlyLabel.classList.add('checked'); 7723 widget.classList.add('monthly-mode'); 7724 } 7725 }); 7726 7727 // ====== Jeffreys Ken Burns Canvas Slideshow ====== 7728 // POI manifest source of truth: papers/jeffrey-platter/manifest.json, 7729 // synced to ./jeffreys-manifest.json by papers/jeffrey-platter/sync.mjs. 7730 // Schema: { buckets: { shoot, candids } }. POI types: 'f'=face, 'b'=body, 'h'=hand. 7731 let headshotsData = {}; 7732 let jeffreysData = {}; 7733 async function loadJeffreysManifest() { 7734 const res = await fetch('./jeffreys-manifest.json', { cache: 'no-cache' }); 7735 const manifest = await res.json(); 7736 headshotsData = manifest.buckets.shoot.items; 7737 jeffreysData = manifest.buckets.candids.items; 7738 } 7739 7740 // AC Screenshots - photographic development moments (environmental/atmospheric) 7741 // URL pattern: https://assets.aesthetic.computer/screenshots/images/{imageRef} 7742 // All 90 screenshots - wide format environmental shots 7743 const screenshotsData = { 7744 'september-16-2022-at-12-37-pm.webp': { focal: [50, 63], pois: [{"t":"s","box":[-0.4,28.3,101.2,70]}], aspect: 0.75, src: 'screenshots' }, 7745 'february-8-2023-at-8-56-pm.webp': { focal: [31, 64], pois: [{"t":"s","box":[-0.1,34.1,62.6,59.7]}], aspect: 0.75, src: 'screenshots' }, 7746 'december-5-2021-at-11-44-pm.webp': { focal: [45, 45], pois: [{"t":"s","box":[0.2,23.2,90.1,42.9]},{"t":"s","box":[15.7,0,71,26.8]}], aspect: 0.75, src: 'screenshots' }, 7747 'may-24-2023-at-6-06-pm.webp': { focal: [50, 50], pois: [], aspect: 0.75, src: 'screenshots' }, 7748 'january-31-2023-at-1-57-pm.webp': { focal: [50, 64], pois: [{"t":"s","box":[0.3,29.2,98.3,69.9]}], aspect: 0.75, src: 'screenshots' }, 7749 'may-15-2023-at-8-29-pm.webp': { focal: [50, 63], pois: [{"t":"s","box":[0.8,28.5,98.9,69.1]}], aspect: 0.75, src: 'screenshots' }, 7750 'december-2nd-2023-at-3-42-pm.webp': { focal: [50, 57], pois: [{"t":"s","box":[-0.1,15.8,100.2,83]}], aspect: 0.75, src: 'screenshots' }, 7751 'october-29-2023-at-6-28-pm.webp': { focal: [67, 76], pois: [{"t":"s","box":[36.1,52.6,60.8,47]},{"t":"s","box":[44.7,30.8,29,21.2]}], aspect: 0.75, src: 'screenshots' }, 7752 'september-11-2023-at-12-35-pm.webp': { focal: [50, 62], pois: [{"t":"s","box":[-2.3,24.3,104.2,74.4]}], aspect: 0.75, src: 'screenshots' }, 7753 'june-14-2023-at-6-33-pm.webp': { focal: [52, 70], pois: [{"t":"s","box":[6.2,41.6,92.5,57.2]}], aspect: 0.75, src: 'screenshots' }, 7754 'june-22-2023-at-4-22-pm.webp': { focal: [65, 55], pois: [{"t":"s","box":[30.7,25.3,68.9,59]}], aspect: 0.75, src: 'screenshots' }, 7755 'february-28-2023-at-11-47-pm.webp': { focal: [53, 73], pois: [{"t":"s","box":[17.9,47.8,70,50.4]}], aspect: 0.75, src: 'screenshots' }, 7756 'november-25-2022-at-7-34-pm.webp': { focal: [29, 30], pois: [{"t":"s","box":[45,17.3,33.4,29.3]},{"t":"s","box":[10.7,14.3,36.7,32.3]}], aspect: 0.75, src: 'screenshots' }, 7757 'may-6-2023-at-10-21-pm.webp': { focal: [46, 67], pois: [{"t":"s","box":[3.5,32.5,84.6,68]}], aspect: 0.75, src: 'screenshots' }, 7758 'july-11-2023-at-10-41-am.webp': { focal: [50, 50], pois: [], aspect: 0.75, src: 'screenshots' }, 7759 'january-30-2024-at-6-59-pm.webp': { focal: [50, 50], pois: [], aspect: 0.75, src: 'screenshots' }, 7760 'january-29-2024-at-4-10-pm.webp': { focal: [50, 50], pois: [], aspect: 0.75, src: 'screenshots' }, 7761 'august-22-2023-at-6-51-pm.webp': { focal: [37, 58], pois: [{"t":"s","box":[0.6,18.3,72.8,78.7]}], aspect: 0.75, src: 'screenshots' }, 7762 'january-8-2023-at-4-12-pm.webp': { focal: [50, 63], pois: [{"t":"s","box":[0.3,25.4,99.4,74.4]}], aspect: 0.75, src: 'screenshots' }, 7763 'november-2-2023-at-10-15-pm.webp': { focal: [53, 40], pois: [{"t":"s","box":[41.8,31.4,21.6,17]}], aspect: 0.75, src: 'screenshots' }, 7764 'june-7-2023-at-10-50-pm.webp': { focal: [50, 63], pois: [{"t":"s","box":[-1.2,27.8,102,70.1]}], aspect: 0.75, src: 'screenshots' }, 7765 'october-4-2022-at-11-12-am.webp': { focal: [55, 72], pois: [{"t":"s","box":[10.5,44.4,88.5,55.3]}], aspect: 0.75, src: 'screenshots' }, 7766 'february-1-2023-at-5-37-pm.webp': { focal: [54, 62], pois: [{"t":"s","box":[16.5,43.3,74.9,37.8]}], aspect: 0.75, src: 'screenshots' }, 7767 'march-17-2023-at-1-29-pm.webp': { focal: [49, 65], pois: [{"t":"s","box":[0.8,32,95.8,66.7]}], aspect: 0.75, src: 'screenshots' }, 7768 'august-11-2023-at-5-36-pm.webp': { focal: [68, 70], pois: [{"t":"s","box":[36.8,41.3,62.9,57.4]}], aspect: 0.75, src: 'screenshots' }, 7769 'october-27-2023-at-2-32-pm.webp': { focal: [45, 30], pois: [{"t":"s","box":[34.2,47.9,59.5,34.7]},{"t":"s","box":[11.9,7.8,66.2,44.7]}], aspect: 0.75, src: 'screenshots' }, 7770 'january-20-2023-at-2-05-am.webp': { focal: [50, 70], pois: [{"t":"s","box":[0.2,41.7,100,57.4]}], aspect: 0.75, src: 'screenshots' }, 7771 'november-2-2022-at-5-41-pm.webp': { focal: [33, 48], pois: [{"t":"s","box":[1,17,64.6,61.9]}], aspect: 0.75, src: 'screenshots' }, 7772 'october-9-2023-at-3-17-pm.webp': { focal: [53, 70], pois: [{"t":"s","box":[6.7,40.2,91.8,59]}], aspect: 0.75, src: 'screenshots' }, 7773 'june-17-2023-at-7-34-pm.webp': { focal: [50, 59], pois: [{"t":"s","box":[-0.8,19.2,101,79.7]}], aspect: 0.75, src: 'screenshots' }, 7774 'june-8-2023-at-7-34-pm.webp': { focal: [58, 69], pois: [{"t":"s","box":[15.3,38.9,86.1,60.2]}], aspect: 0.75, src: 'screenshots' }, 7775 'june-3-2023-at-10-04-pm.webp': { focal: [75, 59], pois: [{"t":"s","box":[-0.5,42.2,49.5,53.8]},{"t":"s","box":[49.8,25.3,50.2,66.9]}], aspect: 0.75, src: 'screenshots' }, 7776 'june-26-2023-at-6-52-pm.webp': { focal: [65, 40], pois: [{"t":"s","box":[42.7,23.3,44.8,32.8]},{"t":"s","box":[42.4,51,26.3,18.4]}], aspect: 0.75, src: 'screenshots' }, 7777 'september-17-2023-at-12-21-am.webp': { focal: [50, 39], pois: [{"t":"s","box":[15.1,70.1,58.4,29.3]},{"t":"s","box":[0.9,0.7,98.2,76.8]}], aspect: 0.75, src: 'screenshots' }, 7778 'march-13-2024-at-11-13-pm.webp': { focal: [41, 56], pois: [{"t":"s","box":[10.5,30.3,61.7,50.9]}], aspect: 0.75, src: 'screenshots' }, 7779 'july-4-2023-at-3-26-pm.webp': { focal: [45, 81], pois: [{"t":"s","box":[7.4,63,76.1,36]}], aspect: 0.75, src: 'screenshots' }, 7780 'january-27-2023-at-5-16-pm.webp': { focal: [32, 77], pois: [{"t":"s","box":[48.5,69.8,26.1,18.3]},{"t":"s","box":[18.9,67,25.8,19.1]}], aspect: 0.75, src: 'screenshots' }, 7781 'january-21-2024-at-12-25-pm.webp': { focal: [56, 77], pois: [{"t":"s","box":[13.3,54.7,86.1,44.4]}], aspect: 0.75, src: 'screenshots' }, 7782 'september-30-2023-at-1-15-am.webp': { focal: [57, 47], pois: [{"t":"s","box":[28.7,13.8,55.8,67]}], aspect: 0.75, src: 'screenshots' }, 7783 'june-1-2023-at-6-52-pm.webp': { focal: [50, 57], pois: [{"t":"s","box":[0.7,15.1,98.6,84]}], aspect: 0.75, src: 'screenshots' }, 7784 'june-13-2023-at-8-24-pm.webp': { focal: [48, 75], pois: [{"t":"s","box":[0.3,49.5,94.8,50.5]}], aspect: 0.75, src: 'screenshots' }, 7785 'june-9-2023-at-11-00-pm.webp': { focal: [44, 71], pois: [{"t":"s","box":[5.9,43.6,76,55.1]}], aspect: 0.75, src: 'screenshots' }, 7786 'april-24-2023-at-2-26-pm.webp': { focal: [50, 31], pois: [{"t":"s","box":[13.2,13.5,72.8,35.1]},{"t":"s","box":[41.4,59.4,58.6,39.1]}], aspect: 0.75, src: 'screenshots' }, 7787 'september-29-2023-at-5-58-pm.webp': { focal: [50, 63], pois: [{"t":"s","box":[-0.5,29.1,100.6,68.7]}], aspect: 0.75, src: 'screenshots' }, 7788 'june-3-2023-at-7-25-pm.webp': { focal: [55, 61], pois: [{"t":"s","box":[11.9,27.7,87.2,65.8]}], aspect: 0.75, src: 'screenshots' }, 7789 'may-4-2023-at-8-59-pm.webp': { focal: [49, 50], pois: [{"t":"s","box":[38.2,41.6,21,17.2]}], aspect: 0.75, src: 'screenshots' }, 7790 'may-6-2023-at-11-59-am.webp': { focal: [54, 69], pois: [{"t":"s","box":[8.9,38.4,90.3,60.9]}], aspect: 0.75, src: 'screenshots' }, 7791 'december-31-2021-at-4-54-pm.webp': { focal: [50, 61], pois: [{"t":"s","box":[-1.1,23.3,101.5,76.2]}], aspect: 0.75, src: 'screenshots' }, 7792 'august-6-2022-at-11-16-am.webp': { focal: [56, 68], pois: [{"t":"s","box":[12.9,40,86.7,55.6]},{"t":"s","box":[0.2,-0.3,97.6,44]}], aspect: 0.75, src: 'screenshots' }, 7793 'november-28-2023-at-12-13-pm.webp': { focal: [53, 76], pois: [{"t":"s","box":[29.7,53.3,46.7,45.5]}], aspect: 0.75, src: 'screenshots' }, 7794 'september-30-2023-at-1-21-pm.webp': { focal: [61, 54], pois: [{"t":"s","box":[21.9,14.9,77.8,78.2]}], aspect: 0.75, src: 'screenshots' }, 7795 'february-3-2023-at-9-45-pm.webp': { focal: [50, 56], pois: [{"t":"s","box":[-0.2,12.9,100.6,86.7]}], aspect: 0.75, src: 'screenshots' }, 7796 'september-27-2023-at-1-42-am.webp': { focal: [72, 63], pois: [{"t":"s","box":[13.8,22.7,34.3,25.2]},{"t":"s","box":[44.4,30.3,55.9,66.2]}], aspect: 0.75, src: 'screenshots' }, 7797 'november-28-2023-at-4-35-pm.webp': { focal: [66, 78], pois: [{"t":"s","box":[36.4,55.9,59.3,44.2]}], aspect: 0.75, src: 'screenshots' }, 7798 'april-13-2023-at-11-18-am.webp': { focal: [51, 70], pois: [{"t":"s","box":[2.2,40,96.9,59.5]}], aspect: 0.75, src: 'screenshots' }, 7799 'february-13-2023-at-9-22-pm.webp': { focal: [50, 58], pois: [{"t":"s","box":[-0.5,16.7,101.1,82.2]}], aspect: 0.75, src: 'screenshots' }, 7800 'july-14-2022-at-3-56-pm.webp': { focal: [68, 43], pois: [{"t":"s","box":[1,57.2,49.5,37.5]},{"t":"s","box":[52.4,54.4,45.1,39.1]},{"t":"s","box":[38.7,22.5,57.9,41.4]}], aspect: 0.75, src: 'screenshots' }, 7801 'april-24-2023-at-9-42-am.webp': { focal: [50, 62], pois: [{"t":"s","box":[-0.6,26.1,101.2,71.9]}], aspect: 0.75, src: 'screenshots' }, 7802 'june-26-2022-at-4-49-pm.webp': { focal: [50, 65], pois: [{"t":"s","box":[1.9,31.3,97.2,67.4]}], aspect: 0.75, src: 'screenshots' }, 7803 'september-16-2023-at-10-42-pm.webp': { focal: [49, 46], pois: [{"t":"s","box":[34,16.1,49.7,42.1]},{"t":"s","box":[16.1,17.4,65.4,58]}], aspect: 0.75, src: 'screenshots' }, 7804 'january-19-2023-at-3-46-pm.webp': { focal: [68, 70], pois: [{"t":"s","box":[36.2,39.8,63.4,59.7]}], aspect: 0.75, src: 'screenshots' }, 7805 'november-7-2022-at-4-40-pm.webp': { focal: [58, 73], pois: [{"t":"s","box":[20.4,51.5,75.4,43.7]}], aspect: 0.75, src: 'screenshots' }, 7806 'june-8-2023-at-6-57-pm.webp': { focal: [50, 64], pois: [{"t":"s","box":[0.5,29.6,98.8,69.4]}], aspect: 0.75, src: 'screenshots' }, 7807 'march-1-2023-at-12-26-pm.webp': { focal: [55, 69], pois: [{"t":"s","box":[10.2,39.1,89,60.4]}], aspect: 0.75, src: 'screenshots' }, 7808 'february-27-2023-at-6-07-pm.webp': { focal: [46, 55], pois: [{"t":"s","box":[8.1,29.5,75,50.3]}], aspect: 0.75, src: 'screenshots' }, 7809 'may-4-2023-at-2-33-pm.webp': { focal: [50, 71], pois: [{"t":"s","box":[0.9,41.6,98.6,58.3]}], aspect: 0.75, src: 'screenshots' }, 7810 'september-1-2023-at-11-18-pm.webp': { focal: [52, 77], pois: [{"t":"s","box":[21.3,58.5,61.5,37.8]}], aspect: 0.75, src: 'screenshots' }, 7811 'december-5-2023-at-1-28-pm.webp': { focal: [51, 80], pois: [{"t":"s","box":[25.8,60.8,49.9,38.9]}], aspect: 0.75, src: 'screenshots' }, 7812 'april-21-2023-at-11-12-am.webp': { focal: [54, 53], pois: [{"t":"s","box":[9.4,6.8,88.9,91.6]}], aspect: 0.75, src: 'screenshots' }, 7813 'january-7-2023-at-8-16-pm.webp': { focal: [48, 54], pois: [{"t":"s","box":[-3.3,11.8,101.8,85.4]}], aspect: 0.75, src: 'screenshots' }, 7814 'june-1-2023-at-8-18-pm.webp': { focal: [62, 59], pois: [{"t":"s","box":[23.2,26.2,76.7,66.1]}], aspect: 0.75, src: 'screenshots' }, 7815 'march-28-2023-at-1-05-pm.webp': { focal: [76, 69], pois: [{"t":"s","box":[63.9,59.4,24.3,19.3]},{"t":"s","box":[38.1,60.5,23,19.3]},{"t":"s","box":[9.1,60.5,20.1,15.4]}], aspect: 0.75, src: 'screenshots' }, 7816 'september-4-2022-at-6-30-pm.webp': { focal: [51, 76], pois: [{"t":"s","box":[1.5,53.2,98.5,46.2]}], aspect: 0.75, src: 'screenshots' }, 7817 'february-7-2023-at-4-20-pm.webp': { focal: [44, 46], pois: [{"t":"s","box":[2.4,5.9,83.8,79.4]}], aspect: 0.75, src: 'screenshots' }, 7818 'august-21-2022-at-10-19-pm.webp': { focal: [51, 61], pois: [{"t":"s","box":[1.8,22.5,97.8,76.7]}], aspect: 0.75, src: 'screenshots' }, 7819 'july-11-2022-at-10-55-pm.webp': { focal: [52, 73], pois: [{"t":"s","box":[31.5,57,41.8,31.3]},{"t":"s","box":[25.4,12.7,49.4,23.1]}], aspect: 0.75, src: 'screenshots' }, 7820 'july-22-2022-at-12-36-am.webp': { focal: [43, 38], pois: [{"t":"s","box":[16.5,19.2,53.3,38.6]}], aspect: 0.75, src: 'screenshots' }, 7821 'november-9-2023-at-4-28-pm.webp': { focal: [60, 72], pois: [{"t":"s","box":[20.7,44.5,78.5,55.1]}], aspect: 0.75, src: 'screenshots' }, 7822 'july-21-2022-at-12-09-am.webp': { focal: [48, 59], pois: [{"t":"s","box":[0.3,19.2,95,79.6]}], aspect: 0.75, src: 'screenshots' }, 7823 'july-4-2022-at-12-11-am.webp': { focal: [49, 65], pois: [{"t":"s","box":[0.8,30.1,96.3,68.7]}], aspect: 0.75, src: 'screenshots' }, 7824 'may-27-2023-at-1-18-pm.webp': { focal: [54, 70], pois: [{"t":"s","box":[8,40.5,91.9,59.1]}], aspect: 0.75, src: 'screenshots' }, 7825 'march-8-2024-at-6-40-pm.webp': { focal: [49, 50], pois: [{"t":"s","box":[8.6,23.4,79.8,53.2]}], aspect: 0.75, src: 'screenshots' }, 7826 'february-8-2023-at-12-15-pm.webp': { focal: [37, 60], pois: [{"t":"s","box":[5.3,34.7,64.3,49.8]}], aspect: 0.75, src: 'screenshots' }, 7827 'december-22-2022-at-11-51-pm.webp': { focal: [57, 49], pois: [{"t":"s","box":[13.5,20.2,86.9,58.3]}], aspect: 0.75, src: 'screenshots' }, 7828 'february-5-2023-at-3-10-pm.webp': { focal: [51, 71], pois: [{"t":"s","box":[13.5,43.3,75.5,56.1]}], aspect: 0.75, src: 'screenshots' }, 7829 'june-17-2023-at-7-55-pm.webp': { focal: [45, 81], pois: [{"t":"s","box":[3.5,62.2,83.1,37.8]},{"t":"s","box":[56.4,64.2,40,35.7]}], aspect: 0.75, src: 'screenshots' }, 7830 'september-12-2023-at-6-31-pm.webp': { focal: [50, 63], pois: [{"t":"s","box":[-0.2,27.3,101.2,70.5]}], aspect: 0.75, src: 'screenshots' }, 7831 'december-4-2023-at-1-08-pm.webp': { focal: [63, 70], pois: [{"t":"s","box":[24.9,41.6,75.3,57.7]}], aspect: 0.75, src: 'screenshots' }, 7832 'october-17-2022-at-9-33-pm.webp': { focal: [35, 27], pois: [{"t":"s","box":[17.3,12.8,34.7,27.5]}], aspect: 0.75, src: 'screenshots' }, 7833 'november-7-2023-at-10-49-pm.webp': { focal: [47, 52], pois: [{"t":"s","box":[34.3,44.5,24.6,15.8]}], aspect: 0.75, src: 'screenshots' } 7834 }; 7835 7836 // Merge all image data. Filled in by buildImageIndex() after 7837 // loadJeffreysManifest() resolves. 7838 let allImagesData = {}; 7839 let jeffreysImages = []; 7840 const screenshotsImages = Object.keys(screenshotsData); 7841 let headshotsImages = []; 7842 let allImages = []; 7843 function buildImageIndex() { 7844 allImagesData = { ...jeffreysData, ...screenshotsData, ...headshotsData }; 7845 jeffreysImages = Object.keys(jeffreysData); 7846 headshotsImages = Object.keys(headshotsData); 7847 allImages = [...jeffreysImages, ...screenshotsImages, ...headshotsImages]; 7848 } 7849 const DEBUG_FACES = false; // Set true for labels and coordinates 7850 const SHOW_POI_BOXES = true; // Matrix-style detection boxes (aesthetic) 7851 const POI_GLOW = true; // Soft radial glow on detected faces/bodies 7852 7853 // Aesthetic POI box colors - cycling rainbow 7854 const poiColors = [ 7855 'rgba(255, 107, 157, 0.7)', // pink 7856 'rgba(78, 205, 196, 0.7)', // cyan 7857 'rgba(255, 217, 61, 0.7)', // gold 7858 'rgba(107, 203, 119, 0.7)', // green 7859 'rgba(180, 130, 255, 0.7)', // purple 7860 'rgba(255, 160, 100, 0.7)', // orange 7861 ]; 7862 let poiColorIndex = 0; 7863 let poiAnimPhase = 0; 7864 7865 function initJeffreysSlideshow() { 7866 const container = document.getElementById('jeffreysSlideshow'); 7867 if (!container) return; 7868 7869 // Create canvas 7870 const canvas = document.createElement('canvas'); 7871 canvas.className = 'jeffreys-canvas'; 7872 container.appendChild(canvas); 7873 const ctx = canvas.getContext('2d'); 7874 7875 // Separate narrow (portrait) and wide images from all sources 7876 const narrowImages = allImages.filter(name => allImagesData[name].aspect < 0.7); 7877 const wideImages = allImages.filter(name => allImagesData[name].aspect >= 0.7); 7878 7879 // Shuffle both arrays 7880 const shuffledNarrow = [...narrowImages].sort(() => Math.random() - 0.5); 7881 const shuffledWide = [...wideImages].sort(() => Math.random() - 0.5); 7882 7883 // Create display queue: singles for wide, pairs for narrow 7884 const displayQueue = []; 7885 let narrowIdx = 0; 7886 let wideIdx = 0; 7887 7888 // Interleave: 2 wide singles, then 1 narrow pair, repeat 7889 while (wideIdx < shuffledWide.length || narrowIdx < shuffledNarrow.length) { 7890 // Add 2 wide singles 7891 for (let i = 0; i < 2 && wideIdx < shuffledWide.length; i++) { 7892 displayQueue.push([shuffledWide[wideIdx++]]); 7893 } 7894 // Add 1 narrow pair (collage) 7895 if (narrowIdx + 1 < shuffledNarrow.length) { 7896 displayQueue.push([shuffledNarrow[narrowIdx], shuffledNarrow[narrowIdx + 1]]); 7897 narrowIdx += 2; 7898 } else if (narrowIdx < shuffledNarrow.length) { 7899 displayQueue.push([shuffledNarrow[narrowIdx++]]); 7900 } 7901 } 7902 7903 // Preload images - non-blocking with decode() 7904 const loadedImages = {}; 7905 const loadingImages = {}; // Track in-progress loads 7906 7907 function preloadImage(name) { 7908 // Already loaded 7909 if (loadedImages[name]) return Promise.resolve(loadedImages[name]); 7910 // Already loading - return existing promise 7911 if (loadingImages[name]) return loadingImages[name]; 7912 7913 // Start new load 7914 loadingImages[name] = new Promise(resolve => { 7915 const img = new Image(); 7916 img.crossOrigin = 'anonymous'; 7917 img.onload = async () => { 7918 // Decode image off main thread to prevent jank when drawing 7919 try { 7920 if (img.decode) await img.decode(); 7921 } catch (e) { /* ignore decode errors */ } 7922 loadedImages[name] = img; 7923 delete loadingImages[name]; 7924 resolve(img); 7925 }; 7926 img.onerror = () => { 7927 delete loadingImages[name]; 7928 resolve(null); 7929 }; 7930 // Check source type: screenshots, headshots, or jeffreys 7931 const data = allImagesData[name]; 7932 if (data && data.src === 'screenshots') { 7933 img.src = `https://assets.aesthetic.computer/screenshots/images/${name}`; 7934 } else if (data && data.src === 'headshots') { 7935 img.src = `https://assets.aesthetic.computer/jeffreys/shoot/${name}`; 7936 } else { 7937 img.src = `https://assets.aesthetic.computer/jeffreys/jpg/${name}.jpg`; 7938 } 7939 }); 7940 return loadingImages[name]; 7941 } 7942 7943 // Preload multiple images in parallel without blocking 7944 function preloadBatch(names) { 7945 names.forEach(name => preloadImage(name)); 7946 } 7947 7948 // Load logo image for canvas rendering 7949 const logoImg = new Image(); 7950 logoImg.crossOrigin = 'anonymous'; 7951 logoImg.src = 'https://aesthetic.computer/purple-pals.svg'; 7952 let logoLoaded = false; 7953 logoImg.onload = () => { logoLoaded = true; }; 7954 7955 // Generate QR code for give.aesthetic.computer (bottom-right of canvas) 7956 // Using larger cell size (6) for lower density / fewer pixels look 7957 const giveQr = qrcode(0, 'L'); 7958 giveQr.addData('https://give.aesthetic.computer'); 7959 giveQr.make(); 7960 const giveQrImg = new Image(); 7961 giveQrImg.src = giveQr.createDataURL(6, 0); // 6px cells for chunkier pixels 7962 let giveQrLoaded = false; 7963 giveQrImg.onload = () => { giveQrLoaded = true; }; 7964 7965 // A/B/C test: use the global variant (already set above) 7966 // A: $64, B: $128, C: $8 monthly 7967 7968 // Track current price and intensity from the widget 7969 let currentPrice = `$${abcTestStartPrice}`; 7970 let currentIntensity = 1; // 0-4 intensity level 7971 function updatePriceFromWidget() { 7972 const widget = container.closest('.gift-widget'); 7973 if (widget) { 7974 const amountEl = widget.querySelector('.gift-amount'); 7975 if (amountEl) { 7976 currentPrice = amountEl.textContent; 7977 // Check intensity class 7978 if (amountEl.classList.contains('intensity-4')) currentIntensity = 4; 7979 else if (amountEl.classList.contains('intensity-3')) currentIntensity = 3; 7980 else if (amountEl.classList.contains('intensity-2')) currentIntensity = 2; 7981 else if (amountEl.classList.contains('intensity-1')) currentIntensity = 1; 7982 else currentIntensity = 0; 7983 } 7984 } 7985 } 7986 // Watch for price changes 7987 const priceObserver = new MutationObserver(updatePriceFromWidget); 7988 const widget = container.closest('.gift-widget'); 7989 if (widget) { 7990 const amountEl = widget.querySelector('.gift-amount'); 7991 if (amountEl) { 7992 priceObserver.observe(amountEl, { childList: true, characterData: true, subtree: true, attributes: true }); 7993 currentPrice = amountEl.textContent; 7994 updatePriceFromWidget(); 7995 } 7996 } 7997 7998 // Resize canvas to container 7999 let canvasW = 0, canvasH = 0; 8000 let needsRender = false; // Flag to trigger immediate render after resize 8001 function resizeCanvas() { 8002 const rect = container.getBoundingClientRect(); 8003 canvasW = rect.width; 8004 canvasH = rect.height; 8005 canvas.width = canvasW * window.devicePixelRatio; 8006 canvas.height = canvasH * window.devicePixelRatio; 8007 canvas.style.width = canvasW + 'px'; 8008 canvas.style.height = canvasH + 'px'; 8009 ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); 8010 needsRender = true; // Mark for immediate re-render 8011 } 8012 8013 let currentDisplayIndex = 0; 8014 let currentAlpha = 0; 8015 let targetImages = []; 8016 let floatOffset = 0; 8017 let poiIndex = 0; // Current POI being focused on 8018 let poiTimer = 0; // Timer for switching POIs 8019 let globalRotation = 0; // Slow global rotation 8020 let colorPhase = 0; // For color channel cycling 8021 let verticalScrollPhase = 0; // For vertical Ken Burns scrolling (0 to 1) 8022 let swingPhase = 0; // Swinging rope-like motion 8023 let zoomPhase = 0; // Subtle zoom breathing 8024 let scrollZoom = 0; // Scroll-based Ken Burns zoom (-1 to 1) 8025 let scrollZoomTarget = 0; // Target scroll zoom (smoothly interpolated) 8026 8027 // Floating gives are now rendered as HTML overlay (see startFloatingGives) 8028 8029 // Track scroll position for Ken Burns scroll-zoom effect 8030 function updateScrollZoom() { 8031 const slideshow = container; 8032 if (!slideshow) return; 8033 const rect = slideshow.getBoundingClientRect(); 8034 const viewportH = window.innerHeight; 8035 // Calculate how far the slideshow has scrolled through the viewport 8036 // 0 = slideshow just entering viewport from bottom 8037 // 0.5 = slideshow centered in viewport 8038 // 1 = slideshow leaving viewport at top 8039 const scrollProgress = 1 - ((rect.top + rect.height / 2) / (viewportH + rect.height)); 8040 // Map to -1 to 1 range, with 0 when centered 8041 // Creates zoom-out at top, zoom-in at bottom effect 8042 scrollZoomTarget = (scrollProgress - 0.5) * 2; 8043 // Clamp to reasonable range 8044 scrollZoomTarget = Math.max(-1, Math.min(1, scrollZoomTarget)); 8045 } 8046 8047 // Listen for scroll events 8048 window.addEventListener('scroll', updateScrollZoom, { passive: true }); 8049 window.addEventListener('resize', updateScrollZoom, { passive: true }); 8050 updateScrollZoom(); // Initial calculation 8051 8052 // Easing function for smooth bouncy motion 8053 function easeInOutSine(t) { 8054 return -(Math.cos(Math.PI * t) - 1) / 2; 8055 } 8056 8057 // Bouncy ease for more organic feel 8058 function bouncyEase(t) { 8059 const s = Math.sin(t * Math.PI * 2); 8060 const s2 = Math.sin(t * Math.PI * 4) * 0.3; 8061 return (s + s2 * 0.5 + 1) / 2; 8062 } 8063 8064 // Draw POI with simple Ken Burns - just face/body with nice zoom and pan 8065 // verticalProgress is 0-1 for top-to-bottom scroll animation 8066 // zoomBreath and swingOffset add organic swaying motion 8067 function drawPoiWithContext(img, data, poi, destX, destY, destW, destH, alpha, rotation = 0, side = 'left', verticalProgress = 0.5, zoomBreath = 0, swingOffset = 0) { 8068 const imgW = img.width; 8069 const imgH = img.height; 8070 8071 // Get POI box in image pixels 8072 const poiX = imgW * poi.box[0] / 100; 8073 const poiY = imgH * poi.box[1] / 100; 8074 const poiW = imgW * poi.box[2] / 100; 8075 const poiH = imgH * poi.box[3] / 100; 8076 const poiCenterX = poiX + poiW / 2; 8077 const poiCenterY = poiY + poiH / 2; 8078 8079 ctx.save(); 8080 8081 // Calculate zoom to COVER the canvas (always fill entire viewport) 8082 // Add subtle zoom breathing (±3%) for organic feel 8083 const scaleX = destW / imgW; 8084 const scaleY = destH / imgH; 8085 const zoomPulse = 1.0 + zoomBreath * 0.03; // Subtle 3% zoom oscillation 8086 const coverScale = Math.max(scaleX, scaleY) * 1.30 * zoomPulse; // 30% extra to prevent edge gaps + breathing 8087 8088 // Scaled image dimensions 8089 const scaledW = imgW * coverScale; 8090 const scaledH = imgH * coverScale; 8091 8092 // Position POI at 30% or 70% horizontally (avoiding center where logo is) 8093 // Add swing offset for rope-like delayed motion 8094 const swingX = swingOffset * 8; // Subtle horizontal swing 8095 const swingY = Math.sin(swingOffset * 0.7) * 5; // Delayed vertical bounce 8096 const targetX = (side === 'left' ? destW * 0.30 : destW * 0.70) + swingX; 8097 8098 // For vertical images (aspect < 0.8), use vertical scroll animation 8099 // BUT: If we have a FACE, keep it on screen - don't scroll it off 8100 const isVertical = data.aspect < 0.8; 8101 const isFace = poi.t === 'f'; 8102 let targetY; 8103 if (isVertical && !isFace) { 8104 // Scroll from top (20%) to bottom (80%) based on verticalProgress - slower, dreamier 8105 // Only do this for non-face POIs (bodies, hands, computer pics) 8106 const easedProgress = (Math.sin((verticalProgress - 0.5) * Math.PI) + 1) / 2; // Ease in-out 8107 targetY = destH * (0.15 + easedProgress * 0.7) + swingY; 8108 } else if (isVertical && isFace) { 8109 // For faces in vertical images, keep face centered with gentle breathing only 8110 // Slight vertical drift but constrained to keep face visible 8111 const gentleDrift = Math.sin(verticalProgress * Math.PI) * 0.08; // Very subtle ±8% drift 8112 targetY = destH * (0.40 + gentleDrift) + swingY; // Face stays near 40% (upper-mid) 8113 } else { 8114 targetY = destH * 0.45 + swingY; // Slightly above center with swing 8115 } 8116 8117 // Calculate offset to position POI center at target position 8118 let offsetX = targetX - (poiCenterX * coverScale); 8119 let offsetY = targetY - (poiCenterY * coverScale); 8120 8121 // Clamp offsets so image always covers the entire canvas with extra padding 8122 // Add 5px overdraw padding to prevent edge artifacts from filters 8123 const edgePad = 5; 8124 const minOffsetX = destW - scaledW + edgePad; 8125 const maxOffsetX = -edgePad; 8126 offsetX = Math.max(minOffsetX, Math.min(maxOffsetX, offsetX)); 8127 8128 // Bottom edge >= destH, top edge <= 0 8129 const minOffsetY = destH - scaledH + edgePad; 8130 const maxOffsetY = -edgePad; 8131 offsetY = Math.max(minOffsetY, Math.min(maxOffsetY, offsetY)); 8132 8133 // Create soft edge mask for blending (fade edges for layering) 8134 const gradient = ctx.createLinearGradient( 8135 side === 'left' ? destW * 0.5 : 0, 8136 0, 8137 side === 'left' ? destW : destW * 0.5, 8138 0 8139 ); 8140 if (side === 'left') { 8141 gradient.addColorStop(0, `rgba(255,255,255,${alpha})`); 8142 gradient.addColorStop(1, `rgba(255,255,255,0)`); 8143 } else { 8144 gradient.addColorStop(0, `rgba(255,255,255,0)`); 8145 gradient.addColorStop(1, `rgba(255,255,255,${alpha})`); 8146 } 8147 8148 // Crisp filter with sharpening via contrast boost 8149 ctx.filter = 'saturate(1.08) contrast(1.05)'; 8150 ctx.globalAlpha = alpha; 8151 8152 // Draw image covering entire canvas 8153 ctx.drawImage(img, 0, 0, imgW, imgH, offsetX, offsetY, scaledW, scaledH); 8154 8155 // Reset filter 8156 ctx.filter = 'none'; 8157 8158 // Draw aesthetic animated POI box (no labels) 8159 if (SHOW_POI_BOXES) { 8160 // Cycle through colors with animation 8161 const color = poiColors[(poiColorIndex++) % poiColors.length]; 8162 const pulse = 0.5 + 0.5 * Math.sin(poiAnimPhase); 8163 8164 ctx.strokeStyle = color; 8165 ctx.lineWidth = 2 + pulse; 8166 ctx.setLineDash([8, 4]); // Dashed line for matrix look 8167 ctx.lineDashOffset = -poiAnimPhase * 5; // Animated dash 8168 8169 // Map POI box to canvas coords 8170 const canvasPoiX = offsetX + poiX * coverScale; 8171 const canvasPoiY = offsetY + poiY * coverScale; 8172 const canvasPoiW = poiW * coverScale; 8173 const canvasPoiH = poiH * coverScale; 8174 ctx.strokeRect(canvasPoiX, canvasPoiY, canvasPoiW, canvasPoiH); 8175 8176 // Corner accents 8177 ctx.setLineDash([]); 8178 ctx.lineWidth = 3; 8179 const cornerLen = Math.min(12, canvasPoiW * 0.2, canvasPoiH * 0.2); 8180 // Top-left 8181 ctx.beginPath(); 8182 ctx.moveTo(canvasPoiX, canvasPoiY + cornerLen); 8183 ctx.lineTo(canvasPoiX, canvasPoiY); 8184 ctx.lineTo(canvasPoiX + cornerLen, canvasPoiY); 8185 ctx.stroke(); 8186 // Top-right 8187 ctx.beginPath(); 8188 ctx.moveTo(canvasPoiX + canvasPoiW - cornerLen, canvasPoiY); 8189 ctx.lineTo(canvasPoiX + canvasPoiW, canvasPoiY); 8190 ctx.lineTo(canvasPoiX + canvasPoiW, canvasPoiY + cornerLen); 8191 ctx.stroke(); 8192 // Bottom-left 8193 ctx.beginPath(); 8194 ctx.moveTo(canvasPoiX, canvasPoiY + canvasPoiH - cornerLen); 8195 ctx.lineTo(canvasPoiX, canvasPoiY + canvasPoiH); 8196 ctx.lineTo(canvasPoiX + cornerLen, canvasPoiY + canvasPoiH); 8197 ctx.stroke(); 8198 // Bottom-right 8199 ctx.beginPath(); 8200 ctx.moveTo(canvasPoiX + canvasPoiW - cornerLen, canvasPoiY + canvasPoiH); 8201 ctx.lineTo(canvasPoiX + canvasPoiW, canvasPoiY + canvasPoiH); 8202 ctx.lineTo(canvasPoiX + canvasPoiW, canvasPoiY + canvasPoiH - cornerLen); 8203 ctx.stroke(); 8204 } 8205 8206 ctx.filter = 'none'; 8207 ctx.restore(); 8208 8209 return { offsetX, offsetY, scaledW, scaledH }; 8210 } 8211 8212 // Draw image with focal point offset to left or right (avoiding center logo) 8213 // Animation flow: WIDE BODY SHOT → ZOOM INTO FACE/ACTIONS 8214 // drift controls progression: 0 = wide establishing shot, 1 = tight on face/action 8215 function drawImageFocused(img, data, destX, destY, destW, destH, alpha, drift, rotation = 0, forceOffset = null) { 8216 const imgW = img.width; 8217 const imgH = img.height; 8218 8219 // Separate POIs by type for staged reveal 8220 const allPois = data.pois || []; 8221 const bodies = allPois.filter(poi => poi.t === 'b'); 8222 const faces = allPois.filter(poi => poi.t === 'f'); 8223 const hands = allPois.filter(poi => poi.t === 'h'); 8224 8225 // Priority order: FACES FIRST (most important), then bodies, rarely hands 8226 // When faces exist, focus primarily on them 8227 const stagedPois = []; 8228 8229 // Stage 1: If we have faces, they are the star - add them first and multiple times 8230 if (faces.length > 0) { 8231 // Add each face twice to spend more time on faces 8232 for (const face of faces) { 8233 stagedPois.push({ ...face, stage: 'closeup' }); 8234 stagedPois.push({ ...face, stage: 'closeup' }); // Double time on faces 8235 } 8236 } 8237 8238 // Stage 2: Add widest body only as brief establishing shot 8239 const biggestBody = bodies.sort((a, b) => (b.box[2] * b.box[3]) - (a.box[2] * a.box[3]))[0]; 8240 if (biggestBody && faces.length === 0) { 8241 // Only use body as main focus if no faces 8242 stagedPois.push({ ...biggestBody, stage: 'wide' }); 8243 } else if (biggestBody) { 8244 // Brief body shot between face focus 8245 stagedPois.push({ ...biggestBody, stage: 'wide' }); 8246 } 8247 8248 // Stage 3: Hand/action details (only if no faces and significant) 8249 for (const hand of hands) { 8250 const area = hand.box[2] * hand.box[3]; 8251 if (area > 5 && faces.length === 0) { // Only if no faces 8252 stagedPois.push({ ...hand, stage: 'action' }); 8253 } 8254 } 8255 8256 // If no POIs, fall back to default focal 8257 if (stagedPois.length === 0) { 8258 stagedPois.push({ t: 'default', box: [data.focal[0] - 10, data.focal[1] - 10, 20, 20], stage: 'wide' }); 8259 } 8260 8261 // Calculate focal point - progress through stages with drift 8262 // drift cycles 0→1, we map to staged progression 8263 const stagePhase = (drift * 0.4 * stagedPois.length) % stagedPois.length; // Even slower 8264 const stageIdx = Math.floor(stagePhase); 8265 const nextStageIdx = (stageIdx + 1) % stagedPois.length; 8266 const rawBlend = stagePhase - stageIdx; 8267 const blend = easeInOutSine(rawBlend); 8268 8269 const poi1 = getPoiCenter(stagedPois[stageIdx]); 8270 const poi2 = getPoiCenter(stagedPois[nextStageIdx]); 8271 8272 // Smooth blend between stages 8273 const focalX = poi1.x + (poi2.x - poi1.x) * blend; 8274 const focalY = poi1.y + (poi2.y - poi1.y) * blend; 8275 const currentPoi = stagedPois[stageIdx]; 8276 8277 // ZOOM: Wide for bodies, tight for faces/hands 8278 // Body/wide stage = 1.0-1.1 zoom (show more) 8279 // Face closeup = 1.4-1.6 zoom (intimate) 8280 // Hand/action = 1.3-1.5 zoom (detail) 8281 let zoomTarget1, zoomTarget2; 8282 const getZoomForStage = (poi) => { 8283 if (poi.stage === 'wide' || poi.t === 'b') return 1.1; 8284 if (poi.t === 'f') return 1.7; // Tighter on faces 8285 if (poi.t === 'h') return 1.4; 8286 return 1.15; 8287 }; 8288 8289 zoomTarget1 = getZoomForStage(stagedPois[stageIdx]); 8290 zoomTarget2 = getZoomForStage(stagedPois[nextStageIdx]); 8291 8292 // Smooth zoom transition with gentle pulse 8293 const baseZoom = zoomTarget1 + (zoomTarget2 - zoomTarget1) * blend; 8294 const zoomPulse = Math.sin(drift * Math.PI * 4) * 0.03; // Subtle breathing 8295 const zoom = baseZoom + zoomPulse; 8296 8297 // Calculate source rect to show focal point 8298 const canvasRatio = destW / destH; 8299 const imgRatio = imgW / imgH; 8300 8301 let srcW, srcH, srcX, srcY; 8302 8303 if (imgRatio > canvasRatio) { 8304 // Image wider than canvas - crop sides 8305 srcH = imgH / zoom; 8306 srcW = srcH * canvasRatio; 8307 } else { 8308 // Image taller than canvas - crop top/bottom 8309 srcW = imgW / zoom; 8310 srcH = srcW / canvasRatio; 8311 } 8312 8313 // OFFSET: Position face LEFT or RIGHT of center (not in center where logo is) 8314 // Decide offset direction based on face position - if face is left of center, show it left 8315 // If face is right of center, show it right. This feels more natural. 8316 let offsetDirection = forceOffset; 8317 if (offsetDirection === null) { 8318 // Auto-detect: push face to whichever side it's already leaning toward 8319 offsetDirection = focalX < 50 ? 'left' : 'right'; 8320 } 8321 8322 // Calculate offset: we want face at ~30% or ~70% of canvas, not 50% 8323 // This means shifting the source crop so focal point maps to side of canvas 8324 const targetCanvasPercent = offsetDirection === 'left' ? 0.30 : 0.70; 8325 const focalImgX = imgW * focalX / 100; 8326 const focalImgY = imgH * focalY / 100; 8327 8328 // Position srcX so that focalImgX lands at targetCanvasPercent of destW 8329 srcX = focalImgX - srcW * targetCanvasPercent; 8330 srcY = focalImgY - srcH / 2; // Vertical still centered on face 8331 8332 // Clamp to image bounds 8333 srcX = Math.max(0, Math.min(imgW - srcW, srcX)); 8334 srcY = Math.max(0, Math.min(imgH - srcH, srcY)); 8335 8336 ctx.save(); 8337 ctx.globalAlpha = alpha; 8338 8339 // Apply rotation around center of destination 8340 const centerX = destX + destW / 2; 8341 const centerY = destY + destH / 2; 8342 ctx.translate(centerX, centerY); 8343 ctx.rotate(rotation); 8344 ctx.translate(-centerX, -centerY); 8345 8346 // Scale up slightly to cover corners when rotated 8347 const rotScale = 1 + Math.abs(rotation) * 0.5; 8348 ctx.translate(centerX, centerY); 8349 ctx.scale(rotScale, rotScale); 8350 ctx.translate(-centerX, -centerY); 8351 8352 ctx.drawImage(img, srcX, srcY, srcW, srcH, destX, destY, destW, destH); 8353 8354 // Soft radial glow highlights on faces and bodies 8355 if (POI_GLOW && data.pois && data.pois.length > 0) { 8356 for (const poi of data.pois) { 8357 // Only glow faces and large bodies 8358 const area = poi.box[2] * poi.box[3]; 8359 if (poi.t === 'h') continue; // Skip hands 8360 if (poi.t === 'b' && area < 20) continue; // Skip small bodies 8361 8362 const poiImgX = imgW * poi.box[0] / 100; 8363 const poiImgY = imgH * poi.box[1] / 100; 8364 const poiImgW = imgW * poi.box[2] / 100; 8365 const poiImgH = imgH * poi.box[3] / 100; 8366 8367 // Check if POI is in visible region 8368 if (poiImgX + poiImgW > srcX && poiImgX < srcX + srcW && 8369 poiImgY + poiImgH > srcY && poiImgY < srcY + srcH) { 8370 // Map POI center to canvas coords 8371 const poiCenterX = destX + (poiImgX + poiImgW / 2 - srcX) / srcW * destW; 8372 const poiCenterY = destY + (poiImgY + poiImgH / 2 - srcY) / srcH * destH; 8373 const poiRadius = Math.max(poiImgW / srcW * destW, poiImgH / srcH * destH) * 1.3; 8374 8375 // Create soft radial gradient glow - warmer for faces, cooler for bodies 8376 const glow = ctx.createRadialGradient( 8377 poiCenterX, poiCenterY, 0, 8378 poiCenterX, poiCenterY, poiRadius 8379 ); 8380 8381 if (poi.t === 'f') { 8382 // Warm golden glow for faces 8383 glow.addColorStop(0, 'rgba(255, 245, 220, 0.18)'); 8384 glow.addColorStop(0.4, 'rgba(255, 230, 200, 0.10)'); 8385 glow.addColorStop(1, 'rgba(255, 220, 180, 0)'); 8386 } else { 8387 // Subtle cool glow for bodies 8388 glow.addColorStop(0, 'rgba(220, 230, 255, 0.12)'); 8389 glow.addColorStop(0.4, 'rgba(200, 215, 255, 0.06)'); 8390 glow.addColorStop(1, 'rgba(180, 200, 255, 0)'); 8391 } 8392 8393 ctx.fillStyle = glow; 8394 ctx.fillRect(destX, destY, destW, destH); 8395 } 8396 } 8397 } 8398 8399 // Debug: verbose POI info (only when DEBUG_FACES is true) 8400 if (DEBUG_FACES && data.pois) { 8401 const typeColors = { f: 'rgba(255, 0, 255, 0.9)', b: 'rgba(0, 255, 255, 0.7)', h: 'rgba(255, 255, 0, 0.5)' }; 8402 ctx.lineWidth = 2; 8403 for (const poi of data.pois) { 8404 const poiImgX = imgW * poi.box[0] / 100; 8405 const poiImgY = imgH * poi.box[1] / 100; 8406 const poiImgW = imgW * poi.box[2] / 100; 8407 const poiImgH = imgH * poi.box[3] / 100; 8408 if (poiImgX + poiImgW > srcX && poiImgX < srcX + srcW && 8409 poiImgY + poiImgH > srcY && poiImgY < srcY + srcH) { 8410 ctx.strokeStyle = typeColors[poi.t] || 'white'; 8411 const canvasX = destX + (poiImgX - srcX) / srcW * destW; 8412 const canvasY = destY + (poiImgY - srcY) / srcH * destH; 8413 const canvasFW = poiImgW / srcW * destW; 8414 const canvasFH = poiImgH / srcH * destH; 8415 ctx.strokeRect(canvasX, canvasY, canvasFW, canvasFH); 8416 // Label 8417 ctx.font = '10px monospace'; 8418 ctx.fillStyle = typeColors[poi.t]; 8419 ctx.fillText(poi.t.toUpperCase(), canvasX + 2, canvasY + 10); 8420 } 8421 } 8422 // Crosshair on current focal point 8423 const focalCanvasX = destX + (focalImgX - srcX) / srcW * destW; 8424 const focalCanvasY = destY + (focalImgY - srcY) / srcH * destH; 8425 ctx.fillStyle = 'rgba(0, 255, 0, 0.9)'; 8426 ctx.strokeStyle = 'rgba(0, 255, 0, 0.9)'; 8427 ctx.fillRect(focalCanvasX - 15, focalCanvasY - 2, 30, 4); 8428 ctx.fillRect(focalCanvasX - 2, focalCanvasY - 15, 4, 30); 8429 } 8430 8431 // Aesthetic POI boxes (multicolored, animated, no labels) 8432 if (SHOW_POI_BOXES && !DEBUG_FACES && data.pois) { 8433 for (let i = 0; i < data.pois.length; i++) { 8434 const poi = data.pois[i]; 8435 const poiImgX = imgW * poi.box[0] / 100; 8436 const poiImgY = imgH * poi.box[1] / 100; 8437 const poiImgW = imgW * poi.box[2] / 100; 8438 const poiImgH = imgH * poi.box[3] / 100; 8439 if (poiImgX + poiImgW > srcX && poiImgX < srcX + srcW && 8440 poiImgY + poiImgH > srcY && poiImgY < srcY + srcH) { 8441 8442 const canvasX = destX + (poiImgX - srcX) / srcW * destW; 8443 const canvasY = destY + (poiImgY - srcY) / srcH * destH; 8444 const canvasFW = poiImgW / srcW * destW; 8445 const canvasFH = poiImgH / srcH * destH; 8446 8447 // Cycle colors per box 8448 const color = poiColors[(poiColorIndex + i) % poiColors.length]; 8449 const pulse = 0.5 + 0.5 * Math.sin(poiAnimPhase + i * 0.5); 8450 8451 ctx.strokeStyle = color; 8452 ctx.lineWidth = 1.5 + pulse; 8453 ctx.setLineDash([6, 3]); 8454 ctx.lineDashOffset = -poiAnimPhase * 3; 8455 ctx.strokeRect(canvasX, canvasY, canvasFW, canvasFH); 8456 8457 // Corner accents 8458 ctx.setLineDash([]); 8459 ctx.lineWidth = 2.5; 8460 const cornerLen = Math.min(10, canvasFW * 0.15, canvasFH * 0.15); 8461 ctx.beginPath(); 8462 ctx.moveTo(canvasX, canvasY + cornerLen); 8463 ctx.lineTo(canvasX, canvasY); 8464 ctx.lineTo(canvasX + cornerLen, canvasY); 8465 ctx.moveTo(canvasX + canvasFW - cornerLen, canvasY); 8466 ctx.lineTo(canvasX + canvasFW, canvasY); 8467 ctx.lineTo(canvasX + canvasFW, canvasY + cornerLen); 8468 ctx.moveTo(canvasX, canvasFH + canvasY - cornerLen); 8469 ctx.lineTo(canvasX, canvasFH + canvasY); 8470 ctx.lineTo(canvasX + cornerLen, canvasFH + canvasY); 8471 ctx.moveTo(canvasX + canvasFW - cornerLen, canvasFH + canvasY); 8472 ctx.lineTo(canvasX + canvasFW, canvasFH + canvasY); 8473 ctx.lineTo(canvasX + canvasFW, canvasFH + canvasY - cornerLen); 8474 ctx.stroke(); 8475 } 8476 } 8477 } 8478 8479 ctx.restore(); 8480 } 8481 8482 // State for transitions - NOW WITH TWO SIMULTANEOUS POIs (left + right) 8483 let transitionProgress = 0; // 0 = showing old, 1 = showing new 8484 let isTransitioning = false; 8485 let transitionFadeSpeed = 0.012; // Updated during transition start 8486 let currentPoiIndex = 0; 8487 let poiPhase = 0; // For slow progression through POIs 8488 let initialFadeIn = 1; // Start at 1 - no fade, show immediately 8489 let isInitialized = false; // True once first images are loaded and ready 8490 8491 // Two POIs always visible: left and right 8492 let leftPoi = null; 8493 let rightPoi = null; 8494 let prevLeftPoi = null; 8495 let prevRightPoi = null; 8496 8497 // Build separate lists for jeffreys, headshots, and screenshots POIs 8498 const jeffreysPois = []; 8499 const headshotsPois = []; 8500 const screenshotsPois = []; 8501 8502 for (const name of jeffreysImages) { 8503 const data = allImagesData[name]; 8504 if (data.pois && data.pois.length > 0) { 8505 for (const poi of data.pois) { 8506 if (poi.t === 'h') continue; 8507 if (poi.t === 'b' && poi.box[2] * poi.box[3] < 15) continue; 8508 jeffreysPois.push({ name, poi, data }); 8509 } 8510 } else { 8511 // No POIs - create virtual POI from focal point 8512 const virtualPoi = { t: 'v', box: [data.focal[0] - 15, data.focal[1] - 15, 30, 30] }; 8513 jeffreysPois.push({ name, poi: virtualPoi, data }); 8514 } 8515 } 8516 8517 // Headshots are face-focused professional shots (temporarily disabled) 8518 // for (const name of headshotsImages) { 8519 // const data = allImagesData[name]; 8520 // if (data.pois && data.pois.length > 0) { 8521 // for (const poi of data.pois) { 8522 // headshotsPois.push({ name, poi, data }); 8523 // } 8524 // } 8525 // } 8526 8527 for (const name of screenshotsImages) { 8528 const data = allImagesData[name]; 8529 if (data.pois && data.pois.length > 0) { 8530 for (const poi of data.pois) { 8531 if (poi.t === 'h') continue; 8532 if (poi.t === 'b' && poi.box[2] * poi.box[3] < 15) continue; 8533 screenshotsPois.push({ name, poi, data }); 8534 } 8535 } else { 8536 // Screenshots have no faces - create virtual POI from focal point for environmental shots 8537 const virtualPoi = { t: 'v', box: [data.focal[0] - 20, data.focal[1] - 15, 40, 30] }; 8538 screenshotsPois.push({ name, poi: virtualPoi, data }); 8539 } 8540 } 8541 8542 // Shuffle all arrays 8543 const shuffle = arr => { 8544 for (let i = arr.length - 1; i > 0; i--) { 8545 const j = Math.floor(Math.random() * (i + 1)); 8546 [arr[i], arr[j]] = [arr[j], arr[i]]; 8547 } 8548 return arr; 8549 }; 8550 shuffle(jeffreysPois); 8551 shuffle(headshotsPois); 8552 shuffle(screenshotsPois); 8553 8554 // Prioritize faces in each (headshots are all faces already) 8555 jeffreysPois.sort((a, b) => (b.poi.t === 'f' ? 1 : 0) - (a.poi.t === 'f' ? 1 : 0)); 8556 screenshotsPois.sort((a, b) => (b.poi.t === 'f' ? 1 : 0) - (a.poi.t === 'f' ? 1 : 0)); 8557 8558 // Interleave: jeffreys, headshot (25% chance), screenshot, repeat 8559 // Headshots sprinkled in probabilistically for variety 8560 const allPois = []; 8561 const maxLen = Math.max(jeffreysPois.length, screenshotsPois.length, headshotsPois.length); 8562 let headshotIdx = 0; 8563 for (let i = 0; i < maxLen; i++) { 8564 if (i < jeffreysPois.length) allPois.push(jeffreysPois[i]); 8565 // 25% chance to insert a headshot after a jeffreys pic 8566 if (headshotIdx < headshotsPois.length && Math.random() < 0.25) { 8567 allPois.push(headshotsPois[headshotIdx++]); 8568 } 8569 if (i < screenshotsPois.length) allPois.push(screenshotsPois[i]); 8570 // 15% chance to insert a headshot after a screenshot 8571 if (headshotIdx < headshotsPois.length && Math.random() < 0.15) { 8572 allPois.push(headshotsPois[headshotIdx++]); 8573 } 8574 } 8575 // Add any remaining headshots at the end 8576 while (headshotIdx < headshotsPois.length) { 8577 allPois.push(headshotsPois[headshotIdx++]); 8578 } 8579 8580 // Frame timing for 24fps throttle 8581 let lastFrameTime = 0; 8582 const TARGET_FPS = 24; 8583 const FRAME_INTERVAL = 1000 / TARGET_FPS; // ~41.67ms 8584 let isPaused = false; // Pause when not visible 8585 8586 function render(timestamp) { 8587 // Skip rendering if paused (not visible) 8588 if (isPaused) { 8589 requestAnimationFrame(render); 8590 return; 8591 } 8592 8593 // Initialize lastFrameTime on first call (timestamp may be undefined if called directly) 8594 if (!timestamp || lastFrameTime === 0) { 8595 lastFrameTime = timestamp || performance.now(); 8596 requestAnimationFrame(render); 8597 return; 8598 } 8599 8600 // Throttle to 24fps (but force render after resize to prevent flicker) 8601 if (timestamp - lastFrameTime < FRAME_INTERVAL && !needsRender) { 8602 requestAnimationFrame(render); 8603 return; 8604 } 8605 needsRender = false; // Clear force-render flag 8606 const deltaTime = Math.min(timestamp - lastFrameTime, 100); // Cap at 100ms to prevent huge jumps 8607 lastFrameTime = timestamp; 8608 8609 // Time multiplier (normalize to 60fps base, so animations stay same speed) 8610 const dt = deltaTime / 16.67; // 16.67ms = 60fps 8611 8612 const w = canvasW; 8613 const h = canvasH; 8614 8615 // Update phases - SLOW and dreamy (time-based) 8616 globalRotation += 0.000015 * dt; // Even slower rotation 8617 floatOffset += 0.00003 * dt; // Slower float 8618 poiPhase += 0.00005 * dt; // Slower POI progression 8619 verticalScrollPhase += 0.0003 * dt; // Much slower vertical scroll (~55 sec full cycle) 8620 if (verticalScrollPhase > 1) verticalScrollPhase = 0; 8621 swingPhase += 0.00007 * dt; // Slow pendulum swing 8622 zoomPhase += 0.00004 * dt; // Very slow zoom breathing 8623 poiAnimPhase += 0.05 * dt; // Animate POI box dash offset 8624 8625 // Update transition progress in render loop (smoother than setInterval) 8626 if (isTransitioning) { 8627 transitionProgress += transitionFadeSpeed * dt; 8628 if (transitionProgress >= 1) { 8629 transitionProgress = 1; 8630 isTransitioning = false; 8631 prevLeftPoi = null; 8632 prevRightPoi = null; 8633 } 8634 } 8635 8636 // Smooth scroll zoom interpolation (buttery smooth tracking, time-based) 8637 scrollZoom += (scrollZoomTarget - scrollZoom) * 0.08 * dt; 8638 8639 // Swinging rope motion - delayed sinusoids for organic feel 8640 const swing1 = Math.sin(swingPhase * Math.PI * 2); 8641 const swing2 = Math.sin(swingPhase * Math.PI * 2 - 0.5); // Delayed follow 8642 const swing3 = Math.sin(swingPhase * Math.PI * 1.3); // Different frequency 8643 8644 // Add scroll-based rotation for extra cinematic parallax feel 8645 const scrollRotation = scrollZoom * 0.008; // Subtle tilt when scrolling 8646 const rotation = swing1 * 0.006 + swing3 * 0.003 + scrollRotation; // Gentle compound rotation + scroll 8647 const wiggleX = swing1 * 1.5 + swing2 * 0.8 + scrollZoom * 3; // Swaying X + scroll parallax 8648 const wiggleY = swing2 * 1.2 + Math.sin(swingPhase * Math.PI * 1.7) * 0.6; // Bouncy Y 8649 8650 // Clear completely each frame 8651 ctx.fillStyle = 'rgb(18, 14, 24)'; 8652 ctx.fillRect(0, 0, w, h); 8653 8654 // Don't render until initialized with loaded images 8655 if (!isInitialized || allPois.length === 0 || (!leftPoi && !rightPoi)) { 8656 requestAnimationFrame(render); 8657 return; 8658 } 8659 8660 // Animate initial fade-in (slow, smooth, time-based) 8661 if (initialFadeIn < 1) { 8662 initialFadeIn += 0.008 * dt; // ~2 seconds to fully fade in 8663 if (initialFadeIn > 1) initialFadeIn = 1; 8664 } 8665 const masterAlpha = easeInOutSine(initialFadeIn); // Smooth ease 8666 8667 ctx.save(); 8668 ctx.translate(w/2 + wiggleX, h/2 + wiggleY); 8669 ctx.rotate(rotation); 8670 ctx.translate(-w/2, -h/2); 8671 8672 // Draw fading out previous POIs during transition (behind current) 8673 // Use a smooth sine-based vertical progress for nice top-to-bottom scroll 8674 const vp = (Math.sin(verticalScrollPhase * Math.PI * 2 - Math.PI/2) + 1) / 2; // 0→1→0 smooth 8675 // Zoom breathing - slow and subtle, PLUS scroll-based Ken Burns zoom 8676 // scrollZoom adds extra zoom based on scroll position: zoom in when scrolling down, out when scrolling up 8677 const zb = Math.sin(zoomPhase * Math.PI * 2) + scrollZoom * 0.5; 8678 // Swing offset for rope-like motion (different phase for left/right) 8679 const swingL = Math.sin(swingPhase * Math.PI * 2); 8680 const swingR = Math.sin(swingPhase * Math.PI * 2 - 0.3); // Slightly delayed for right 8681 8682 // Screenshot shake - faster, jittery digital motion 8683 const shakePhase = swingPhase * 8; // Faster shake for screenshots 8684 const shakeX = Math.sin(shakePhase * Math.PI * 2) * 3 + Math.sin(shakePhase * Math.PI * 5.3) * 1.5; 8685 const shakeY = Math.cos(shakePhase * Math.PI * 1.7) * 2 + Math.sin(shakePhase * Math.PI * 3.1) * 1; 8686 8687 // Ease transition progress for smooth fade (no pop) 8688 const easedTransition = easeInOutSine(transitionProgress); 8689 // More dramatic fade curve - faster in middle 8690 const dramaticFade = Math.pow(easedTransition, 0.7); // Faster fade-in 8691 const dramaticFadeOut = Math.pow(1 - easedTransition, 0.7); // Faster fade-out 8692 8693 if (isTransitioning) { 8694 if (prevLeftPoi) { 8695 const prevImg = loadedImages[prevLeftPoi.name]; 8696 if (prevImg && prevImg.complete && prevImg.naturalWidth > 0) { 8697 const isScreenshot = prevLeftPoi.data.src === 'screenshots'; 8698 const fadeAlpha = dramaticFadeOut * (isScreenshot ? 0.65 : 0.75) * masterAlpha; 8699 const swing = isScreenshot ? shakeX * 0.5 : swingL; 8700 drawPoiWithContext(prevImg, prevLeftPoi.data, prevLeftPoi.poi, 0, 0, w, h, fadeAlpha, 0, 'left', vp, zb, swing); 8701 } 8702 } 8703 if (prevRightPoi) { 8704 const prevImg = loadedImages[prevRightPoi.name]; 8705 if (prevImg && prevImg.complete && prevImg.naturalWidth > 0) { 8706 const isScreenshot = prevRightPoi.data.src === 'screenshots'; 8707 const fadeAlpha = dramaticFadeOut * (isScreenshot ? 0.6 : 0.7) * masterAlpha; 8708 const swing = isScreenshot ? shakeY * 0.5 : swingR; 8709 drawPoiWithContext(prevImg, prevRightPoi.data, prevRightPoi.poi, 0, 0, w, h, fadeAlpha, 0, 'right', vp, zb, swing); 8710 } 8711 } 8712 } 8713 8714 // Draw LEFT POI (always on left side) 8715 if (leftPoi) { 8716 const leftImg = loadedImages[leftPoi.name]; 8717 if (leftImg && leftImg.complete && leftImg.naturalWidth > 0) { 8718 const isScreenshot = leftPoi.data.src === 'screenshots'; 8719 const baseAlpha = isScreenshot ? 0.7 : 0.8; // Faces brighter 8720 const alpha = (isTransitioning ? dramaticFade * baseAlpha : baseAlpha) * masterAlpha; 8721 const swing = isScreenshot ? shakeX : swingL; // Screenshots shake 8722 drawPoiWithContext(leftImg, leftPoi.data, leftPoi.poi, 0, 0, w, h, alpha, 0, 'left', vp, zb, swing); 8723 } 8724 } 8725 8726 // Draw RIGHT POI (always on right side) - slightly lower alpha for layering 8727 if (rightPoi) { 8728 const rightImg = loadedImages[rightPoi.name]; 8729 if (rightImg && rightImg.complete && rightImg.naturalWidth > 0) { 8730 const isScreenshot = rightPoi.data.src === 'screenshots'; 8731 const baseAlpha = isScreenshot ? 0.65 : 0.75; // Faces brighter 8732 const alpha = (isTransitioning ? dramaticFade * baseAlpha : baseAlpha) * masterAlpha; 8733 const swing = isScreenshot ? shakeY : swingR; // Screenshots shake differently 8734 drawPoiWithContext(rightImg, rightPoi.data, rightPoi.poi, 0, 0, w, h, alpha, 0, 'right', vp, zb, swing); 8735 } 8736 } 8737 8738 ctx.restore(); 8739 8740 // Draw POI detection boxes AFTER all transforms (composited on top) 8741 // Psychedelic, subtle, flickering on/off 8742 if (SHOW_POI_BOXES) { 8743 // Flicker: boxes appear/disappear randomly 8744 const flickerPhase = Math.sin(Date.now() / 150) * Math.sin(Date.now() / 370); 8745 const showBoxes = flickerPhase > -0.3; // Show ~70% of the time with irregular rhythm 8746 8747 if (showBoxes) { 8748 ctx.save(); 8749 ctx.filter = 'none'; 8750 8751 // Psychedelic color cycling 8752 const hueShift = (Date.now() / 30) % 360; 8753 const pulse = 0.3 + 0.2 * Math.sin(Date.now() / 200); 8754 8755 // Draw boxes for current left and right POIs 8756 const poisToDraw = []; 8757 if (leftPoi) poisToDraw.push({ poi: leftPoi, side: 'left', swing: swingL }); 8758 if (rightPoi) poisToDraw.push({ poi: rightPoi, side: 'right', swing: swingR }); 8759 8760 for (const { poi: poiData, side, swing } of poisToDraw) { 8761 const img = loadedImages[poiData.name]; 8762 if (!img || !img.complete) continue; 8763 const data = poiData.data; 8764 const imgW = img.width; 8765 const imgH = img.height; 8766 8767 // Recalculate with Ken Burns params (zoomBreath, swing) - MUST MATCH drawPoiWithContext exactly 8768 const scaleX = w / imgW; 8769 const scaleY = h / imgH; 8770 const zoomPulse = 1.0 + zb * 0.03; 8771 const coverScale = Math.max(scaleX, scaleY) * 1.30 * zoomPulse; // 30% extra - matches drawPoiWithContext 8772 const scaledW = imgW * coverScale; 8773 const scaledH = imgH * coverScale; 8774 8775 const poiX = imgW * poiData.poi.box[0] / 100; 8776 const poiY = imgH * poiData.poi.box[1] / 100; 8777 const poiW = imgW * poiData.poi.box[2] / 100; 8778 const poiH = imgH * poiData.poi.box[3] / 100; 8779 const poiCenterX = poiX + poiW / 2; 8780 const poiCenterY = poiY + poiH / 2; 8781 8782 // Include swing offset for Ken Burns tracking 8783 const swingX = swing * 8; 8784 const swingY = Math.sin(swing * 0.7) * 5; 8785 const targetX = (side === 'left' ? w * 0.30 : w * 0.70) + swingX; 8786 8787 // Handle vertical images with vertical scroll (same logic as drawPoiWithContext) 8788 // BUT: Keep faces on screen - don't scroll them off 8789 const isVertical = data.aspect < 0.8; 8790 const isFace = poiData.poi.t === 'f'; 8791 let targetY; 8792 if (isVertical && !isFace) { 8793 const easedProgress = (Math.sin((vp - 0.5) * Math.PI) + 1) / 2; 8794 targetY = h * (0.15 + easedProgress * 0.7) + swingY; 8795 } else if (isVertical && isFace) { 8796 // Face in vertical image - gentle drift only, keep visible 8797 const gentleDrift = Math.sin(vp * Math.PI) * 0.08; 8798 targetY = h * (0.40 + gentleDrift) + swingY; 8799 } else { 8800 targetY = h * 0.45 + swingY; 8801 } 8802 8803 // Apply same edge padding as drawPoiWithContext 8804 const edgePad = 5; 8805 let offsetX = targetX - (poiCenterX * coverScale); 8806 let offsetY = targetY - (poiCenterY * coverScale); 8807 offsetX = Math.max(w - scaledW + edgePad, Math.min(-edgePad, offsetX)); 8808 offsetY = Math.max(h - scaledH + edgePad, Math.min(-edgePad, offsetY)); 8809 8810 // Draw boxes for ALL pois in this image 8811 for (let pi = 0; pi < (data.pois || []).length; pi++) { 8812 const p = data.pois[pi]; 8813 const canvasX = offsetX + (imgW * p.box[0] / 100) * coverScale; 8814 const canvasY = offsetY + (imgH * p.box[1] / 100) * coverScale; 8815 const canvasFW = (imgW * p.box[2] / 100) * coverScale; 8816 const canvasFH = (imgH * p.box[3] / 100) * coverScale; 8817 8818 if (canvasX + canvasFW > 0 && canvasX < w && canvasY + canvasFH > 0 && canvasY < h) { 8819 // Psychedelic hue per POI type 8820 const typeHue = { f: 120, b: 60, h: 0, s: 180 }; 8821 const hue = ((typeHue[p.t] || 120) + hueShift) % 360; 8822 ctx.strokeStyle = `hsla(${hue}, 100%, 60%, ${pulse})`; 8823 ctx.globalAlpha = pulse; 8824 ctx.lineWidth = 1.5; 8825 ctx.strokeRect(canvasX, canvasY, canvasFW, canvasFH); 8826 8827 // Subtle corner accents 8828 const cornerLen = Math.min(10, canvasFW * 0.15, canvasFH * 0.15); 8829 ctx.lineWidth = 2.5; 8830 ctx.strokeStyle = `hsla(${(hue + 60) % 360}, 100%, 70%, ${pulse * 1.5})`; 8831 ctx.beginPath(); 8832 ctx.moveTo(canvasX, canvasY + cornerLen); 8833 ctx.lineTo(canvasX, canvasY); 8834 ctx.lineTo(canvasX + cornerLen, canvasY); 8835 ctx.stroke(); 8836 ctx.beginPath(); 8837 ctx.moveTo(canvasX + canvasFW - cornerLen, canvasY); 8838 ctx.lineTo(canvasX + canvasFW, canvasY); 8839 ctx.lineTo(canvasX + canvasFW, canvasY + cornerLen); 8840 ctx.stroke(); 8841 ctx.beginPath(); 8842 ctx.moveTo(canvasX, canvasY + canvasFH - cornerLen); 8843 ctx.lineTo(canvasX, canvasY + canvasFH); 8844 ctx.lineTo(canvasX + cornerLen, canvasY + canvasFH); 8845 ctx.stroke(); 8846 ctx.beginPath(); 8847 ctx.moveTo(canvasX + canvasFW - cornerLen, canvasY + canvasFH); 8848 ctx.lineTo(canvasX + canvasFW, canvasY + canvasFH); 8849 ctx.lineTo(canvasX + canvasFW, canvasY + canvasFH - cornerLen); 8850 ctx.stroke(); 8851 } 8852 } 8853 } 8854 ctx.restore(); 8855 } 8856 } 8857 8858 // Draw logo and price on canvas (after restore, no filters) 8859 ctx.filter = 'none'; // Ensure no filters affect logo/price 8860 8861 // Floating gives are rendered as HTML overlay for better performance 8862 // (canvas shadowBlur on many characters kills FPS) 8863 8864 // Draw logo and price on canvas 8865 if (logoLoaded && logoImg.complete) { 8866 // Parse current price to get dollar amount for subtle scaling 8867 const priceNum = parseFloat(currentPrice.replace(/[^0-9.]/g, '')) || 128; 8868 // Very subtle scale: $128 = 1.0 (default), range is 0.95 to 1.08 8869 const scaleBase = Math.log2(Math.max(1, priceNum)) / Math.log2(128); // 0 at $1, 1 at $128 8870 const priceScale = 0.95 + scaleBase * 0.13; // Range: 0.95 at $1, 1.08 at $128, ~1.15 at $1000 8871 8872 // Logo size with very subtle price-based scaling 8873 const baseSize = Math.min(w, h); 8874 const logoSize = baseSize * 0.85 * Math.min(priceScale, 1.12); // Cap at 1.12x 8875 const logoX = (w - logoSize) / 2; // Centered 8876 const logoY = (h - logoSize) / 2 + h * 0.08; // Move down slightly 8877 8878 // Draw logo-shaped drop shadow for prominence (soft offset shadow) 8879 ctx.save(); 8880 const logoCenterX = logoX + logoSize / 2; 8881 const logoCenterY = logoY + logoSize / 2; 8882 ctx.globalAlpha = masterAlpha * 0.35; 8883 ctx.filter = 'brightness(0) blur(3px)'; // Black shadow with soft blur 8884 ctx.drawImage(logoImg, logoX + 2, logoY + 3, logoSize, logoSize); // Smaller offset 8885 ctx.restore(); 8886 8887 // Logo intensity effects based on price - always visible, use color shifts not opacity 8888 const t = Date.now(); 8889 let logoHue = 0; 8890 8891 // Draw logo with intensity-based effects 8892 ctx.save(); 8893 8894 if (currentIntensity === 4) { 8895 // MAX INTENSITY: Multiple color-shifted logos stacked, never invisible 8896 logoHue = (t / 12) % 360; 8897 8898 // Draw shadow/echo logos offset and color-shifted 8899 ctx.globalAlpha = masterAlpha * 0.4; 8900 ctx.filter = `hue-rotate(${logoHue + 120}deg) saturate(2)`; 8901 ctx.drawImage(logoImg, logoX - 4, logoY - 2, logoSize, logoSize); 8902 8903 ctx.filter = `hue-rotate(${logoHue + 240}deg) saturate(2)`; 8904 ctx.drawImage(logoImg, logoX + 4, logoY + 2, logoSize, logoSize); 8905 8906 // Main logo with rapid hue shift 8907 ctx.globalAlpha = masterAlpha; 8908 const flashPhase = Math.sin(t / 60); 8909 ctx.filter = `hue-rotate(${logoHue}deg) saturate(${1.5 + flashPhase * 0.5}) brightness(${1 + flashPhase * 0.3})`; 8910 ctx.shadowColor = `hsl(${logoHue}, 100%, 60%)`; 8911 ctx.shadowBlur = 30 + 15 * Math.abs(flashPhase); 8912 ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize); 8913 8914 } else if (currentIntensity === 3) { 8915 // High: pulsing warm glow with color shift, dual logos 8916 logoHue = (t / 25) % 360; 8917 const pulse = Math.sin(t / 150); 8918 8919 // Echo logo 8920 ctx.globalAlpha = masterAlpha * 0.3; 8921 ctx.filter = `hue-rotate(${logoHue + 60}deg)`; 8922 ctx.drawImage(logoImg, logoX + 2 * pulse, logoY + pulse, logoSize, logoSize); 8923 8924 // Main logo 8925 ctx.globalAlpha = masterAlpha; 8926 ctx.filter = `hue-rotate(${logoHue * 0.3}deg) saturate(1.2)`; 8927 ctx.shadowColor = `hsl(${30 + pulse * 30}, 100%, 50%)`; 8928 ctx.shadowBlur = 25 + 10 * pulse; 8929 ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize); 8930 8931 } else if (currentIntensity >= 2) { 8932 // Medium: gentle color pulse 8933 const pulse = Math.sin(t / 400); 8934 ctx.globalAlpha = masterAlpha; 8935 ctx.filter = `saturate(${1.1 + pulse * 0.1})`; 8936 ctx.shadowColor = 'rgba(100, 200, 100, 0.5)'; 8937 ctx.shadowBlur = 20; 8938 ctx.shadowOffsetY = 6; 8939 ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize); 8940 8941 } else if (currentIntensity >= 1) { 8942 // Low: subtle shadow 8943 ctx.globalAlpha = masterAlpha; 8944 ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; 8945 ctx.shadowBlur = 20; 8946 ctx.shadowOffsetY = 6; 8947 ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize); 8948 8949 } else { 8950 // Default: simple shadow 8951 ctx.globalAlpha = masterAlpha; 8952 ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; 8953 ctx.shadowBlur = 15; 8954 ctx.shadowOffsetY = 4; 8955 ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize); 8956 } 8957 8958 ctx.filter = 'none'; 8959 ctx.restore(); 8960 8961 // Draw price at top-right of logo (very subtle scaling) 8962 ctx.save(); 8963 const fontScale = Math.min(priceScale, 1.08); // Even more subtle for price tag 8964 const fontSize = logoSize * 0.18 * fontScale; 8965 ctx.font = `bold ${fontSize}px 'YWFTProcessing-Regular', monospace`; 8966 8967 // Price background 8968 const priceMetrics = ctx.measureText(currentPrice); 8969 const paddingX = fontSize * 0.4; 8970 const paddingY = fontSize * 0.25; 8971 const priceW = priceMetrics.width + paddingX * 2; 8972 const priceH = fontSize + paddingY * 2; 8973 // Position more to the right of logo 8974 const priceX = logoX + logoSize - priceW * 0.15; 8975 const priceY = logoY - priceH * 0.2; 8976 8977 // Intensity-based colors with more dramatic high-end effects 8978 const t2 = Date.now(); 8979 let bgColor, borderColor, textColor, glowAmt; 8980 8981 if (currentIntensity === 4) { 8982 // MAX: Flashing high contrast, rainbow cycling 8983 const flash = Math.sin(t2 / 60); 8984 const hue = (t2 / 8) % 360; 8985 bgColor = flash > 0 ? '#000' : `hsl(${hue}, 80%, 15%)`; 8986 borderColor = `hsl(${hue}, 100%, 60%)`; 8987 textColor = `hsl(${(hue + 180) % 360}, 100%, ${70 + flash * 20}%)`; 8988 glowAmt = 30 + 15 * Math.abs(flash); 8989 } else if (currentIntensity === 3) { 8990 // High: warm pulsing 8991 const pulse = Math.sin(t2 / 200); 8992 bgColor = '#2a2a1a'; 8993 borderColor = `hsl(${40 + pulse * 20}, 100%, 55%)`; 8994 textColor = `hsl(${50 + pulse * 10}, 100%, 70%)`; 8995 glowAmt = 18 + 6 * pulse; 8996 } else if (currentIntensity === 2) { 8997 bgColor = '#1a3a1a'; 8998 borderColor = '#00cc66'; 8999 textColor = '#00ffcc'; 9000 glowAmt = 10; 9001 } else if (currentIntensity === 1) { 9002 bgColor = '#0a2a0a'; 9003 borderColor = '#3a7a3a'; 9004 textColor = '#aaffaa'; 9005 glowAmt = 6; 9006 } else { 9007 bgColor = '#0a2a0a'; 9008 borderColor = '#2a5a2a'; 9009 textColor = '#88ff88'; 9010 glowAmt = 0; 9011 } 9012 9013 // Price background gradient 9014 const bgGrad = ctx.createLinearGradient(priceX, priceY, priceX + priceW, priceY + priceH); 9015 bgGrad.addColorStop(0, bgColor); 9016 bgGrad.addColorStop(1, '#050505'); 9017 ctx.fillStyle = bgGrad; 9018 ctx.globalAlpha = masterAlpha * 0.95; 9019 9020 // Rounded rect background 9021 ctx.beginPath(); 9022 const radius = 4; 9023 ctx.moveTo(priceX + radius, priceY); 9024 ctx.lineTo(priceX + priceW - radius, priceY); 9025 ctx.quadraticCurveTo(priceX + priceW, priceY, priceX + priceW, priceY + radius); 9026 ctx.lineTo(priceX + priceW, priceY + priceH - radius); 9027 ctx.quadraticCurveTo(priceX + priceW, priceY + priceH, priceX + priceW - radius, priceY + priceH); 9028 ctx.lineTo(priceX + radius, priceY + priceH); 9029 ctx.quadraticCurveTo(priceX, priceY + priceH, priceX, priceY + priceH - radius); 9030 ctx.lineTo(priceX, priceY + radius); 9031 ctx.quadraticCurveTo(priceX, priceY, priceX + radius, priceY); 9032 ctx.closePath(); 9033 ctx.fill(); 9034 9035 // Border with intensity glow 9036 if (glowAmt > 0) { 9037 ctx.shadowColor = borderColor; 9038 ctx.shadowBlur = glowAmt; 9039 } 9040 ctx.strokeStyle = borderColor; 9041 ctx.lineWidth = currentIntensity >= 3 ? 4 : 3; // Thicker at high intensity 9042 ctx.globalAlpha = masterAlpha; 9043 ctx.stroke(); 9044 ctx.shadowBlur = 0; 9045 9046 // Price text with intensity effects 9047 ctx.fillStyle = textColor; 9048 ctx.textBaseline = 'middle'; 9049 9050 // At max intensity, add extra text effects 9051 if (currentIntensity === 4) { 9052 ctx.shadowColor = textColor; 9053 ctx.shadowBlur = 10 + 5 * Math.sin(t2 / 50); 9054 } 9055 9056 ctx.fillText(currentPrice, priceX + paddingX, priceY + priceH / 2 + fontSize * 0.08); 9057 ctx.restore(); 9058 } 9059 9060 9061 // Draw QR code in bottom-right corner 9062 // Note: giveQrImg is generated ONCE at init, not every frame 9063 if (giveQrLoaded && giveQrImg.complete) { 9064 ctx.save(); 9065 const qrSize = Math.min(w, h) * 0.28; // 28% of canvas size 9066 const qrPadding = 24; // More margin from edges 9067 const qrX = w - qrSize - qrPadding; // Right edge 9068 const qrY = h - qrSize - qrPadding; // Bottom edge 9069 9070 // Pulsing white glow effect (use cached time for perf) 9071 const qrTime = Date.now(); 9072 const qrPulse = 0.88 + 0.12 * Math.sin(qrTime / 400); 9073 const qrGlow = 10 + 8 * Math.sin(qrTime / 300); 9074 9075 // Bright white background with pulse - no border radius 9076 ctx.globalAlpha = masterAlpha * qrPulse; 9077 ctx.fillStyle = '#ffffff'; 9078 ctx.shadowColor = 'rgba(255, 255, 255, 0.95)'; 9079 ctx.shadowBlur = qrGlow; 9080 const bgPad = 5; // Thin white border 9081 ctx.fillRect(qrX - bgPad, qrY - bgPad, qrSize + bgPad * 2, qrSize + bgPad * 2); 9082 ctx.shadowBlur = 0; 9083 9084 // Draw QR code (pre-generated image, not regenerated each frame) 9085 ctx.globalAlpha = masterAlpha; 9086 ctx.imageSmoothingEnabled = false; // Keep QR crisp 9087 ctx.drawImage(giveQrImg, qrX, qrY, qrSize, qrSize); 9088 ctx.imageSmoothingEnabled = true; 9089 9090 ctx.restore(); 9091 } 9092 9093 requestAnimationFrame(render); 9094 } 9095 9096 function transitionToNextPoi() { 9097 if (allPois.length < 2) return; 9098 9099 // Get next two POIs (different images for left and right) 9100 currentPoiIndex = (currentPoiIndex + 2) % allPois.length; 9101 const nextLeftIdx = currentPoiIndex; 9102 const nextRightIdx = (currentPoiIndex + 1) % allPois.length; 9103 9104 const nextLeftPoi = allPois[nextLeftIdx]; 9105 const nextRightPoi = allPois[nextRightIdx]; 9106 9107 // Check if images are already loaded (they should be from background preload) 9108 const nextLeftImg = loadedImages[nextLeftPoi.name]; 9109 const nextRightImg = loadedImages[nextRightPoi.name]; 9110 9111 if (!nextLeftImg?.complete || !nextLeftImg?.naturalWidth || 9112 !nextRightImg?.complete || !nextRightImg?.naturalWidth) { 9113 // Images not ready - trigger load and skip this transition 9114 preloadImage(nextLeftPoi.name); 9115 preloadImage(nextRightPoi.name); 9116 return; 9117 } 9118 9119 // Preload NEXT batch in background (look ahead 4-8 images) 9120 const lookAhead = 8; 9121 for (let i = 2; i < lookAhead; i++) { 9122 const idx = (currentPoiIndex + i) % allPois.length; 9123 preloadImage(allPois[idx].name); 9124 } 9125 9126 // Store previous for crossfade 9127 prevLeftPoi = leftPoi; 9128 prevRightPoi = rightPoi; 9129 leftPoi = nextLeftPoi; 9130 rightPoi = nextRightPoi; 9131 9132 // Start transition 9133 isTransitioning = true; 9134 transitionProgress = 0; 9135 9136 // Check if current POIs have faces - hold them longer 9137 const hasFaces = (leftPoi?.poi?.t === 'f') || (rightPoi?.poi?.t === 'f'); 9138 // Transition will be handled in render loop for smoother animation 9139 // Store fade speed as state 9140 transitionFadeSpeed = hasFaces ? 0.008 : 0.015; 9141 } 9142 9143 // Start slideshow 9144 async function start() { 9145 resizeCanvas(); 9146 window.addEventListener('resize', resizeCanvas); 9147 9148 // Find first jeffrey (not screenshot) to show immediately 9149 const firstJeffrey = allPois.find(p => p.data.src !== 'screenshots'); 9150 const secondPoi = allPois.find(p => p !== firstJeffrey); 9151 9152 // Get the gift-visual container to add loaded class 9153 const giftVisual = container.closest('.gift-visual'); 9154 9155 if (firstJeffrey) { 9156 // Await first jeffrey image before starting render 9157 await preloadImage(firstJeffrey.name); 9158 leftPoi = firstJeffrey; 9159 isInitialized = true; 9160 9161 // Start render loop with image already loaded 9162 render(); 9163 9164 // Fade in the gift visual (remove blur) 9165 if (giftVisual) { 9166 requestAnimationFrame(() => giftVisual.classList.add('loaded')); 9167 } 9168 9169 // Load second image in background, then set it 9170 if (secondPoi) { 9171 preloadImage(secondPoi.name).then(() => { 9172 rightPoi = secondPoi; 9173 currentPoiIndex = allPois.indexOf(secondPoi); 9174 }); 9175 } 9176 } else { 9177 // Fallback: start render and wait for any image 9178 render(); 9179 if (giftVisual) giftVisual.classList.add('loaded'); 9180 } 9181 9182 // Aggressively preload remaining images in background 9183 const preloadCount = Math.min(16, allPois.length); 9184 preloadBatch(allPois.slice(0, preloadCount).map(p => p.name)); 9185 9186 // Dynamic transition timing - faces held longer 9187 const scheduleNextTransition = () => { 9188 const hasFaces = (leftPoi?.poi?.t === 'f') || (rightPoi?.poi?.t === 'f'); 9189 const holdTime = hasFaces ? 7000 : 4000; 9190 setTimeout(() => { 9191 transitionToNextPoi(); 9192 scheduleNextTransition(); 9193 }, holdTime); 9194 }; 9195 9196 // Start transitions after initial hold 9197 setTimeout(scheduleNextTransition, 4000); 9198 } 9199 9200 start(); 9201 9202 // Pause slideshow when not visible (IntersectionObserver) 9203 const observer = new IntersectionObserver((entries) => { 9204 entries.forEach(entry => { 9205 isPaused = !entry.isIntersecting; 9206 if (!isPaused) { 9207 // Reset lastFrameTime to avoid huge delta on resume 9208 lastFrameTime = 0; 9209 } 9210 }); 9211 }, { threshold: 0.1 }); 9212 observer.observe(container); 9213 } 9214 9215 // Initialize slideshow when DOM ready (after manifest fetch resolves) 9216 async function startJeffreysSlideshow() { 9217 try { 9218 await loadJeffreysManifest(); 9219 } catch (err) { 9220 console.warn('Jeffreys manifest fetch failed; slideshow will run with empty data', err); 9221 } 9222 buildImageIndex(); 9223 initJeffreysSlideshow(); 9224 } 9225 if (document.readyState === 'loading') { 9226 document.addEventListener('DOMContentLoaded', startJeffreysSlideshow); 9227 } else { 9228 startJeffreysSlideshow(); 9229 } 9230 9231 // Language selector (dropdown) 9232 const langSelector = document.getElementById('langSelector'); 9233 const langFlag = document.getElementById('lang-flag'); 9234 const langText = document.getElementById('lang-text'); 9235 const langDropdown = document.getElementById('lang-dropdown'); 9236 const langOptions = langDropdown.querySelectorAll('.lang-option'); 9237 9238 // Currency selector (pills) 9239 const currSelector = document.getElementById('currSelector'); 9240 const currOptions = currSelector.querySelectorAll('.curr-link'); 9241 const currencyPickers = document.querySelectorAll('.currency-picker'); 9242 9243 // Language dropdown toggle 9244 langSelector.addEventListener('click', (e) => { 9245 if (e.target.closest('.lang-option')) return; 9246 e.stopPropagation(); 9247 langSelector.classList.toggle('open'); 9248 }); 9249 9250 // Close dropdown on outside click 9251 document.addEventListener('click', () => { 9252 langSelector.classList.remove('open'); 9253 }); 9254 9255 // Language selection 9256 langOptions.forEach(opt => { 9257 opt.addEventListener('click', (e) => { 9258 e.stopPropagation(); 9259 const lang = opt.dataset.lang; 9260 setLanguage(lang); 9261 updateLangUI(lang); 9262 langSelector.classList.remove('open'); 9263 savePrefs({ lang }); 9264 }); 9265 }); 9266 9267 function updateLangUI(lang) { 9268 const langData = { 9269 en: { flagClass: 'fi fi-us', text: 'English' }, 9270 da: { flagClass: 'fi fi-dk', text: 'Dansk' }, 9271 de: { flagClass: 'fi fi-de', text: 'Deutsch' }, 9272 es: { flagClass: 'fi fi-es', text: 'Español' }, 9273 zh: { flagClass: 'fi fi-cn', text: '中文' } 9274 }; 9275 const info = langData[lang] || langData.en; 9276 // Update flag using class instead of textContent (for flag-icons) 9277 langFlag.className = 'lang-flag ' + info.flagClass; 9278 langText.textContent = info.text; 9279 } 9280 9281 // Consolidated localStorage for gift preferences 9282 const STORAGE_KEY = 'gift-prefs'; 9283 function getPrefs() { 9284 try { 9285 return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; 9286 } catch { return {}; } 9287 } 9288 function savePrefs(updates) { 9289 const prefs = { ...getPrefs(), ...updates }; 9290 localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); 9291 } 9292 9293 // Currency selection (pills) 9294 currOptions.forEach(opt => { 9295 opt.addEventListener('click', (e) => { 9296 e.stopPropagation(); 9297 const curr = opt.dataset.curr; 9298 setCurrency(curr); 9299 savePrefs({ currency: curr }); 9300 }); 9301 }); 9302 9303 function setCurrency(curr) { 9304 currOptions.forEach(opt => opt.classList.toggle('active', opt.dataset.curr === curr)); 9305 currencyPickers.forEach(picker => { 9306 picker.style.display = picker.dataset.for === curr ? '' : 'none'; 9307 }); 9308 9309 // Move the slideshow to the active currency panel (USD or DKK) 9310 const slideshow = document.getElementById('jeffreysSlideshow'); 9311 if (slideshow && (curr === 'usd' || curr === 'dkk')) { 9312 const targetPicker = document.querySelector(`.currency-picker[data-for="${curr}"]`); 9313 const targetVisual = targetPicker?.querySelector('.gift-visual'); 9314 const targetPlaceholder = targetPicker?.querySelector('.jeffreys-slideshow-dkk, .jeffreys-slideshow'); 9315 if (targetVisual && !targetVisual.contains(slideshow)) { 9316 // Insert slideshow as first child of gift-visual 9317 targetVisual.insertBefore(slideshow, targetVisual.firstChild); 9318 // Ensure target visual has loaded class (prevent blur) 9319 targetVisual.classList.add('loaded'); 9320 } 9321 // Trigger canvas resize and unpause after becoming visible 9322 requestAnimationFrame(() => { 9323 window.dispatchEvent(new Event('resize')); 9324 }); 9325 } 9326 9327 // Toggle fiat vs crypto notes 9328 const isCrypto = curr === 'crypto'; 9329 document.querySelectorAll('.fiat-only').forEach(el => el.style.display = isCrypto ? 'none' : ''); 9330 document.querySelectorAll('.crypto-only').forEach(el => el.style.display = isCrypto ? '' : 'none'); 9331 } 9332 9333 // Check for thank-you mode 9334 const urlParams = new URLSearchParams(window.location.search); 9335 const isThanksMode = urlParams.has('thanks') || urlParams.get('thanks') === '1'; 9336 const thanksAmount = urlParams.get('amount'); 9337 const thanksCurrency = urlParams.get('currency') || 'usd'; 9338 9339 // Check for prefilled email (from logged-in users via kidlisp.com or aesthetic.computer) 9340 const prefillEmail = urlParams.get('email'); 9341 if (prefillEmail) { 9342 // Clean up URL without reloading (remove email param for privacy) 9343 const cleanUrl = new URL(window.location.href); 9344 cleanUrl.searchParams.delete('email'); 9345 window.history.replaceState({}, '', cleanUrl.toString()); 9346 } 9347 9348 if (isThanksMode) { 9349 document.body.classList.add('thanks-mode'); 9350 // Update amount display 9351 const amountEn = document.getElementById('thanksAmountEn'); 9352 const amountDa = document.getElementById('thanksAmountDa'); 9353 const formattedAmount = thanksAmount 9354 ? (thanksCurrency === 'dkk' ? `${thanksAmount} kr` : `$${thanksAmount}`) 9355 : '$25'; 9356 if (thanksAmount) { 9357 if (amountEn) amountEn.textContent = formattedAmount; 9358 if (amountDa) amountDa.textContent = formattedAmount; 9359 } 9360 9361 // Format today's date for the canvas 9362 const today = new Date(); 9363 const formattedDate = today.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); 9364 9365 // Generate QR code for give.aesthetic.computer 9366 const qr = qrcode(0, 'M'); 9367 qr.addData('https://give.aesthetic.computer'); 9368 qr.make(); 9369 const qrImg = new Image(); 9370 qrImg.src = qr.createDataURL(4, 0); 9371 9372 // 🌈 PSYCHO THANKS CANVAS with pals.svg + heart behind 9373 const canvas = document.getElementById('thanksCanvas'); 9374 if (canvas) { 9375 const ctx = canvas.getContext('2d'); 9376 const W = canvas.width, H = canvas.height; 9377 9378 // Cancel any previous animation 9379 if (window._thanksAnimFrame) { 9380 cancelAnimationFrame(window._thanksAnimFrame); 9381 } 9382 9383 // Random start time so each gift looks different 9384 let t = Math.random() * 100; 9385 let palsImg = null; 9386 let palsLoaded = false; 9387 9388 // Load pals.svg 9389 const img = new Image(); 9390 img.crossOrigin = 'anonymous'; 9391 img.onload = () => { palsImg = img; palsLoaded = true; }; 9392 img.src = 'https://aesthetic.computer/purple-pals.svg'; 9393 9394 function hslToRgb(h, s, l) { 9395 let r, g, b; 9396 if (s === 0) { r = g = b = l; } 9397 else { 9398 const hue2rgb = (p, q, t) => { 9399 if (t < 0) t += 1; 9400 if (t > 1) t -= 1; 9401 if (t < 1/6) return p + (q - p) * 6 * t; 9402 if (t < 1/2) return q; 9403 if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; 9404 return p; 9405 }; 9406 const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 9407 const p = 2 * l - q; 9408 r = hue2rgb(p, q, h + 1/3); 9409 g = hue2rgb(p, q, h); 9410 b = hue2rgb(p, q, h - 1/3); 9411 } 9412 return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; 9413 } 9414 9415 function drawHeart(cx, cy, size, color, glow = 0) { 9416 ctx.save(); 9417 ctx.translate(cx, cy); 9418 ctx.beginPath(); 9419 const s = size; 9420 ctx.moveTo(0, s * 0.3); 9421 ctx.bezierCurveTo(-s * 0.5, -s * 0.3, -s, s * 0.1, 0, s); 9422 ctx.bezierCurveTo(s, s * 0.1, s * 0.5, -s * 0.3, 0, s * 0.3); 9423 ctx.closePath(); 9424 if (glow > 0) { 9425 ctx.shadowColor = color; 9426 ctx.shadowBlur = glow; 9427 } 9428 ctx.fillStyle = color; 9429 ctx.fill(); 9430 ctx.restore(); 9431 } 9432 9433 // Draw static image (once pals loads) 9434 function drawStaticImage() { 9435 // Background gradient (static purple/dark) 9436 const grad = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, W * 0.7); 9437 grad.addColorStop(0, 'rgb(35, 30, 50)'); 9438 grad.addColorStop(1, 'rgb(20, 15, 30)'); 9439 ctx.fillStyle = grad; 9440 ctx.fillRect(0, 0, W, H); 9441 9442 // Center position for the main composition 9443 const centerX = W / 2; 9444 const centerY = H / 2 - 40; 9445 9446 // Random colors for effects 9447 const colors = [ 9448 'rgb(255, 107, 157)', // pink 9449 'rgb(78, 205, 196)', // cyan 9450 'rgb(255, 217, 61)', // gold 9451 'rgb(107, 203, 119)', // green 9452 'rgb(167, 139, 250)', // purple 9453 'rgb(255, 150, 100)', // orange 9454 ]; 9455 9456 // Soft blurred glow behind pals - multiple layered radial gradients 9457 ctx.save(); 9458 for (let i = 0; i < 6; i++) { 9459 const angle = (i / 6) * Math.PI * 2; 9460 const color = colors[i % colors.length]; 9461 const glowX = centerX + Math.cos(angle) * 80; 9462 const glowY = centerY + Math.sin(angle) * 80; 9463 const glow = ctx.createRadialGradient(glowX, glowY, 0, glowX, glowY, 150); 9464 glow.addColorStop(0, color.replace('rgb', 'rgba').replace(')', ', 0.08)')); 9465 glow.addColorStop(0.5, color.replace('rgb', 'rgba').replace(')', ', 0.03)')); 9466 glow.addColorStop(1, 'rgba(0, 0, 0, 0)'); 9467 ctx.fillStyle = glow; 9468 ctx.fillRect(0, 0, W, H); 9469 } 9470 ctx.restore(); 9471 9472 // Central soft glow 9473 ctx.save(); 9474 const centerGlow = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, 200); 9475 centerGlow.addColorStop(0, 'rgba(167, 139, 250, 0.1)'); 9476 centerGlow.addColorStop(0.4, 'rgba(255, 107, 157, 0.05)'); 9477 centerGlow.addColorStop(1, 'rgba(0, 0, 0, 0)'); 9478 ctx.fillStyle = centerGlow; 9479 ctx.fillRect(0, 0, W, H); 9480 ctx.restore(); 9481 9482 // Heart glow effect 9483 const heartX = centerX + 170; 9484 const heartY = centerY - 160; 9485 ctx.save(); 9486 ctx.beginPath(); 9487 ctx.arc(heartX, heartY + 20, 40, 0, Math.PI * 2); 9488 const heartGlow = ctx.createRadialGradient(heartX, heartY + 20, 0, heartX, heartY + 20, 40); 9489 heartGlow.addColorStop(0, 'rgba(255, 100, 150, 0.3)'); 9490 heartGlow.addColorStop(1, 'rgba(255, 100, 150, 0)'); 9491 ctx.fillStyle = heartGlow; 9492 ctx.fill(); 9493 ctx.restore(); 9494 9495 // Heart in TOP RIGHT - smaller solid pink heart 9496 const heartColor = 'rgb(230, 100, 120)'; 9497 ctx.save(); 9498 ctx.translate(heartX, heartY); 9499 drawHeart(0, 0, 55, heartColor, 0); 9500 ctx.restore(); 9501 9502 // Draw pals.svg centered (moved down) 9503 if (palsLoaded && palsImg) { 9504 const palsSize = 400; 9505 ctx.save(); 9506 ctx.translate(centerX, centerY + 30); 9507 ctx.drawImage(palsImg, -palsSize/2, -palsSize/2, palsSize, palsSize); 9508 ctx.restore(); 9509 } 9510 9511 // QR code in TOP LEFT with white border 9512 if (qrImg.complete && qrImg.naturalWidth > 0) { 9513 ctx.save(); 9514 ctx.fillStyle = 'white'; 9515 ctx.fillRect(14, 14, 82, 82); 9516 ctx.drawImage(qrImg, 18, 18, 74, 74); 9517 ctx.restore(); 9518 } 9519 9520 // Helper to draw text with random colored characters (dot gets distinct color) 9521 function drawRainbowText(text, startX, y, centered = false) { 9522 ctx.save(); 9523 ctx.font = 'bold 36px "YWFTProcessing-Regular", sans-serif'; 9524 ctx.textBaseline = 'middle'; 9525 ctx.shadowBlur = 3; 9526 9527 // Measure total width for centering 9528 let totalWidth = 0; 9529 for (let i = 0; i < text.length; i++) { 9530 totalWidth += ctx.measureText(text[i]).width; 9531 } 9532 9533 let currentX = centered ? startX - totalWidth / 2 : startX; 9534 for (let i = 0; i < text.length; i++) { 9535 const char = text[i]; 9536 let color; 9537 if (char === '.') { 9538 // Dot gets cyan - distinct from other chars 9539 color = 'rgb(78, 205, 196)'; 9540 } else { 9541 color = colors[Math.floor(Math.random() * colors.length)]; 9542 } 9543 ctx.fillStyle = color; 9544 ctx.shadowColor = color; 9545 ctx.fillText(char, currentX, y); 9546 currentX += ctx.measureText(char).width; 9547 } 9548 ctx.restore(); 9549 } 9550 9551 // Text centered under logo (moved up) 9552 const line1Y = H - 90; 9553 const line2Y = H - 48; 9554 9555 // First line: "I gave " + "Aesthetic.Computer" rainbow - centered 9556 ctx.save(); 9557 ctx.font = 'bold 36px "YWFTProcessing-Regular", sans-serif'; 9558 ctx.textBaseline = 'middle'; 9559 ctx.textAlign = 'left'; 9560 9561 // Calculate total width for centering 9562 const iGaveText = 'I gave '; 9563 const acText = 'Aesthetic.Computer'; 9564 const iGaveWidth = ctx.measureText(iGaveText).width; 9565 const acWidth = ctx.measureText(acText).width; 9566 const totalLine1 = iGaveWidth + acWidth; 9567 const line1Start = (W - totalLine1) / 2; 9568 9569 // "I gave " in pink 9570 ctx.fillStyle = 'rgb(255, 180, 200)'; 9571 ctx.shadowColor = 'rgba(255, 180, 200, 0.5)'; 9572 ctx.shadowBlur = 8; 9573 ctx.fillText(iGaveText, line1Start, line1Y); 9574 ctx.restore(); 9575 9576 // "Aesthetic.Computer" in rainbow (dot is cyan) 9577 drawRainbowText(acText, line1Start + iGaveWidth, line1Y, false); 9578 9579 // Second line: price in GREEN, rest in pink - centered 9580 ctx.save(); 9581 ctx.font = 'bold 36px "YWFTProcessing-Regular", sans-serif'; 9582 ctx.textBaseline = 'middle'; 9583 9584 const onText = ' on ' + formattedDate; 9585 const priceWidth = ctx.measureText(formattedAmount).width; 9586 const onWidth = ctx.measureText(onText).width; 9587 const totalLine2 = priceWidth + onWidth; 9588 const line2Start = (W - totalLine2) / 2; 9589 9590 // Price in green 9591 ctx.fillStyle = 'rgb(107, 203, 119)'; 9592 ctx.shadowColor = 'rgba(107, 203, 119, 0.5)'; 9593 ctx.shadowBlur = 8; 9594 ctx.fillText(formattedAmount, line2Start, line2Y); 9595 9596 // " on [date]" in pink 9597 ctx.fillStyle = 'rgb(255, 180, 200)'; 9598 ctx.shadowColor = 'rgba(255, 180, 200, 0.5)'; 9599 ctx.fillText(onText, line2Start + priceWidth, line2Y); 9600 ctx.restore(); 9601 } 9602 9603 // Wait for images to load then draw once 9604 function tryDraw() { 9605 if (palsLoaded && qrImg.complete) { 9606 drawStaticImage(); 9607 } else { 9608 setTimeout(tryDraw, 100); 9609 } 9610 } 9611 tryDraw(); 9612 9613 // Share functionality using Web Share API 9614 const shareBtn = document.getElementById('shareBtn'); 9615 const shareBtnDa = document.getElementById('shareBtnDa'); 9616 const shareBtnDe = document.getElementById('shareBtnDe'); 9617 const shareBtnEs = document.getElementById('shareBtnEs'); 9618 const shareBtnZh = document.getElementById('shareBtnZh'); 9619 9620 async function shareImage() { 9621 // Capture canvas as PNG 9622 const dataUrl = canvas.toDataURL('image/png'); 9623 const blob = await (await fetch(dataUrl)).blob(); 9624 const file = new File([blob], 'i-gave-aesthetic-computer.png', { type: 'image/png' }); 9625 9626 // Translated share text 9627 const shareTexts = { 9628 en: { title: 'I gave to Aesthetic.Computer!', text: `I gave ${formattedAmount} to Aesthetic.Computer on ${formattedDate}! 💖` }, 9629 da: { title: 'Jeg gav til Aesthetic.Computer!', text: `Jeg gav ${formattedAmount} til Aesthetic.Computer d. ${formattedDate}! 💖` }, 9630 de: { title: 'Ich habe Aesthetic.Computer unterstützt!', text: `Ich habe ${formattedAmount} an Aesthetic.Computer am ${formattedDate} gegeben! 💖` }, 9631 es: { title: '¡Le di a Aesthetic.Computer!', text: `¡Le di ${formattedAmount} a Aesthetic.Computer el ${formattedDate}! 💖` }, 9632 zh: { title: '我给了 Aesthetic.Computer!', text: `我在 ${formattedDate} 给了 Aesthetic.Computer ${formattedAmount}!💖` } 9633 }; 9634 const st = shareTexts[currentLang] || shareTexts.en; 9635 9636 // Try Web Share API (great on mobile!) 9637 if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) { 9638 try { 9639 await navigator.share({ 9640 title: st.title, 9641 text: st.text, 9642 files: [file], 9643 url: 'https://give.aesthetic.computer' 9644 }); 9645 return; 9646 } catch (e) { 9647 if (e.name !== 'AbortError') console.log('Share cancelled or failed:', e); 9648 return; // User cancelled, don't fall through 9649 } 9650 } 9651 9652 // Fallback: just download the image 9653 const link = document.createElement('a'); 9654 link.download = 'i-gave-aesthetic-computer.png'; 9655 link.href = dataUrl; 9656 link.click(); 9657 } 9658 9659 [shareBtn, shareBtnDa, shareBtnDe, shareBtnEs, shareBtnZh].forEach(btn => { 9660 if (btn) btn.addEventListener('click', shareImage); 9661 }); 9662 } 9663 } 9664 9665 // Load saved preferences or detect from browser 9666 const savedPrefs = getPrefs(); 9667 let currentLang = savedPrefs.lang; 9668 9669 // URL params can override saved prefs (for shareable links like ?lang=da&currency=dkk) 9670 // Also check pathname for /da, /de, /es, /zh routes (Netlify rewrites don't pass query params to browser) 9671 const langParam = urlParams.get('lang'); 9672 const currencyParam = urlParams.get('currency'); 9673 const pathLang = window.location.pathname.match(/^\/(da|de|es|zh)\/?$/)?.[1]; 9674 9675 if (pathLang) { 9676 // Path-based language (e.g., /da) takes highest priority 9677 currentLang = pathLang; 9678 } else if (langParam && ['en', 'da', 'de', 'es', 'zh'].includes(langParam)) { 9679 currentLang = langParam; 9680 } else if (!currentLang) { 9681 currentLang = navigator.language.startsWith('da') ? 'da' : 'en'; 9682 } 9683 setLanguage(currentLang); 9684 updateLangUI(currentLang); 9685 9686 // Restore saved currency or use URL param (pathLang implies currency too) 9687 if (pathLang === 'da') { 9688 setCurrency('dkk'); 9689 } else if (currencyParam && ['usd', 'dkk', 'crypto', 'paypal'].includes(currencyParam)) { 9690 setCurrency(currencyParam); 9691 } else if (savedPrefs.currency) { 9692 setCurrency(savedPrefs.currency); 9693 } else if (currentLang === 'da') { 9694 setCurrency('dkk'); 9695 } else { 9696 setCurrency('usd'); 9697 } 9698 9699 const SHOP_PANEL_ENABLED = false; 9700 let floatingGivesStarted = false; 9701 9702 // Server suspension state - set true only during real outages. 9703 const SERVER_SUSPENDED = false; 9704 const suspendedMessages = { 9705 en: { offline: 'Servers offline - DigitalOcean suspended', help: 'Your support helps bring them back', give: 'give.aesthetic.computer' }, 9706 da: { offline: 'Servere offline - DigitalOcean suspenderet', help: 'Din støtte hjælper med at bringe dem tilbage', give: 'give.aesthetic.computer' }, 9707 de: { offline: 'Server offline - DigitalOcean gesperrt', help: 'Ihre Unterstützung hilft sie zurückzubringen', give: 'give.aesthetic.computer' }, 9708 es: { offline: 'Servidores sin conexión - DigitalOcean suspendido', help: 'Tu apoyo ayuda a restaurarlos', give: 'give.aesthetic.computer' }, 9709 zh: { offline: '服务器离线 - DigitalOcean 已暂停', help: '您的支持有助于恢复服务', give: 'give.aesthetic.computer' }, 9710 }; 9711 function renderSuspended(container, style = 'card') { 9712 const msg = suspendedMessages[currentLang] || suspendedMessages.en; 9713 if (style === 'card') { 9714 container.innerHTML = ` 9715 <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;min-height:200px;gap:0.6em;text-align:center;padding:1.5em;"> 9716 <div style="font-size:32px;line-height:1;opacity:0.6;">⚡</div> 9717 <div style="color:var(--pink,#ff6b9d);font-size:13px;font-weight:bold;">${msg.offline}</div> 9718 <div style="color:#888;font-size:12px;">${msg.help}</div> 9719 <a href="https://give.aesthetic.computer" style="color:#ff6b9d;font-size:12px;margin-top:0.3em;">${msg.give} -></a> 9720 </div>`; 9721 } else if (style === 'inline') { 9722 container.innerHTML = ` 9723 <div style="color:#888;font-size:12px;padding:0.8em;"> 9724 <span style="color:var(--pink,#ff6b9d);">⚡ ${msg.offline}</span> - <a href="https://give.aesthetic.computer" style="color:#ff6b9d;">${msg.give}</a> 9725 </div>`; 9726 } 9727 } 9728 9729 function setLanguage(lang) { 9730 currentLang = lang; 9731 // Remove all language classes and add current one 9732 document.body.classList.remove('lang-da', 'lang-zh', 'lang-de', 'lang-es'); 9733 if (lang !== 'en') document.body.classList.add(`lang-${lang}`); 9734 9735 // Reload ticker with translated messages 9736 loadRecentGives(); 9737 9738 // Update fiat button text (skip crypto and paypal widgets) 9739 document.querySelectorAll('.gift-widget:not(.crypto):not(.paypal)').forEach(widget => { 9740 const currency = widget.dataset.currency; 9741 const slider = widget.querySelector('input[type="range"]'); 9742 if (!slider) return; 9743 const value = parseInt(slider.value); 9744 const btn = widget.querySelector('.gift-btn'); 9745 const isMonthly = widget._isMonthly ? widget._isMonthly() : false; 9746 const fmtValue = value.toLocaleString('en-US'); 9747 if (currency === 'dkk') { 9748 // Use thin space (&#8201;) to avoid YWFT font rendering regular space as + 9749 btn.innerHTML = lang === 'da' ? `Giv <span class="price">${fmtValue}&#8201;kr</span>` : `Give <span class="price">${fmtValue}&#8201;kr</span>`; 9750 } else if (isMonthly) { 9751 btn.innerHTML = lang === 'da' ? `Giv <span class="price">$${fmtValue}/md</span>` : `Give <span class="price">$${fmtValue}/mo</span>`; 9752 } else { 9753 btn.innerHTML = lang === 'da' ? `Giv <span class="price">$${fmtValue}</span>` : `Give <span class="price">$${fmtValue}</span>`; 9754 } 9755 }); 9756 9757 // Update crypto send button text 9758 document.querySelectorAll('.gift-widget.crypto').forEach(widget => { 9759 const slider = widget.querySelector('.crypto-slider'); 9760 const sendBtn = widget.querySelector('.crypto-send'); 9761 if (slider && sendBtn) { 9762 const currency = slider.dataset.currency; 9763 const value = currency === 'eth' ? parseFloat(slider.value).toFixed(2) : parseInt(slider.value); 9764 const symbol = currency === 'eth' ? 'Ξ' : 'ꜩ'; 9765 sendBtn.textContent = lang === 'da' ? `Send ${value} ${symbol}` : `Send ${value} ${symbol}`; 9766 } 9767 }); 9768 } 9769 9770 // 🔗 Slider sync registry - keeps USD sliders in sync with each other 9771 const sliderRegistry = { 9772 usd: [], // Array of { slider, update, widget, monthlyCheck, monthlyLabel, setIsMonthly } 9773 dkk: [] 9774 }; 9775 9776 function syncSliders(sourceCurrency, sourceSlider) { 9777 const value = parseInt(sourceSlider.value); 9778 const registry = sliderRegistry[sourceCurrency]; 9779 if (!registry) return; 9780 9781 // Update all other sliders of the same currency 9782 registry.forEach(({ slider, update }) => { 9783 if (slider !== sourceSlider) { 9784 slider.value = value; 9785 update(); // Trigger visual update 9786 } 9787 }); 9788 } 9789 9790 // Sync monthly checkbox state across all widgets of same currency 9791 function syncMonthly(sourceCurrency, sourceWidget, isMonthlyState) { 9792 const registry = sliderRegistry[sourceCurrency]; 9793 if (!registry) return; 9794 9795 registry.forEach(({ widget, monthlyCheck, monthlyLabel, setIsMonthly, slider, update }) => { 9796 if (widget !== sourceWidget && monthlyCheck) { 9797 monthlyCheck.checked = isMonthlyState; 9798 monthlyLabel?.classList.toggle('checked', isMonthlyState); 9799 widget.classList.toggle('monthly-mode', isMonthlyState); 9800 9801 // Adjust slider range 9802 const currency = widget.dataset.currency; 9803 if (isMonthlyState) { 9804 slider.min = 1; 9805 slider.max = currency === 'dkk' ? 3500 : 500; 9806 if (parseInt(slider.value) > parseInt(slider.max)) { 9807 slider.value = currency === 'dkk' ? 175 : 25; 9808 } 9809 } else { 9810 slider.min = 1; 9811 slider.max = currency === 'usd' ? 2048 : currency === 'dkk' ? 17500 : 500; 9812 } 9813 9814 setIsMonthly(isMonthlyState); 9815 update(); 9816 } 9817 }); 9818 } 9819 9820 // Slider functionality 9821 document.querySelectorAll('.gift-widget:not(.crypto):not(.paypal)').forEach(widget => { 9822 const currency = widget.dataset.currency; 9823 const slider = widget.querySelector('input[type="range"]'); 9824 const amountEl = widget.querySelector('.gift-amount'); 9825 const btn = widget.querySelector('.gift-btn'); 9826 const monthlyCheck = widget.querySelector('.monthly-checkbox'); 9827 const monthlyLabel = widget.querySelector('.gift-monthly-check'); 9828 if (!slider || !amountEl || !btn) return; 9829 // Initialize isMonthly from A/B/C test state (for USD) or checkbox state 9830 let isMonthly = currency === 'usd' && typeof abcTestIsMonthly !== 'undefined' ? abcTestIsMonthly : (monthlyCheck?.checked || false); 9831 9832 // Monthly checkbox 9833 if (monthlyCheck) { 9834 monthlyCheck.addEventListener('change', async () => { 9835 // If checking monthly and not logged in, show native dialog 9836 if (monthlyCheck.checked && !acUser && !prefillEmail) { 9837 const dialog = document.getElementById('loginModal'); 9838 if (dialog) { 9839 // Show dialog 9840 dialog.showModal(); 9841 9842 // Handle confirm (log in) 9843 const confirmBtn = document.getElementById('loginModalConfirm'); 9844 const cancelBtn = document.getElementById('loginModalCancel'); 9845 9846 const handleConfirm = async () => { 9847 dialog.close(); 9848 cleanup(); 9849 // Keep checkbox checked - they want monthly 9850 // Scroll to login button and blink it 9851 const loginBtn = document.getElementById('footerLoginBtn'); 9852 if (loginBtn) { 9853 loginBtn.scrollIntoView({ behavior: 'smooth', block: 'center' }); 9854 setTimeout(() => { 9855 loginBtn.classList.add('blinking'); 9856 setTimeout(() => loginBtn.classList.remove('blinking'), 2000); 9857 }, 400); 9858 } 9859 }; 9860 9861 const handleCancel = () => { 9862 dialog.close(); 9863 cleanup(); 9864 // Just close - do nothing else 9865 }; 9866 9867 const cleanup = () => { 9868 confirmBtn?.removeEventListener('click', handleConfirm); 9869 cancelBtn?.removeEventListener('click', handleCancel); 9870 }; 9871 9872 confirmBtn?.addEventListener('click', handleConfirm); 9873 cancelBtn?.addEventListener('click', handleCancel); 9874 9875 // Don't uncheck - let them proceed with monthly even without login 9876 // The checkbox stays checked 9877 } 9878 } 9879 9880 isMonthly = monthlyCheck.checked; 9881 monthlyLabel.classList.toggle('checked', isMonthly); 9882 widget.classList.toggle('monthly-mode', isMonthly); 9883 9884 // Adjust slider range for monthly vs one-time 9885 if (isMonthly) { 9886 slider.min = currency === 'dkk' ? 1 : 1; 9887 slider.max = currency === 'dkk' ? 3500 : 500; 9888 slider.step = currency === 'dkk' ? 1 : 1; 9889 if (parseInt(slider.value) > parseInt(slider.max)) slider.value = currency === 'dkk' ? 175 : 25; 9890 } else { 9891 slider.min = currency === 'dkk' ? 1 : 1; 9892 slider.max = currency === 'usd' ? 2048 : currency === 'dkk' ? 17500 : 500; 9893 slider.step = currency === 'dkk' ? 1 : 1; 9894 } 9895 update(); 9896 9897 // Sync monthly state to other widgets 9898 syncMonthly(currency, widget, isMonthly); 9899 }); 9900 } 9901 9902 function format(v) { 9903 // Use simple comma separator to avoid locale-specific characters 9904 // that may render incorrectly in YWFT font (e.g., narrow no-break space → +) 9905 const formatted = v.toLocaleString('en-US'); 9906 // For DKK, don't include space - we'll add spacing via CSS on the 'kr' suffix 9907 return currency === 'dkk' ? formatted + '|kr' : `$${formatted}`; 9908 } 9909 9910 const investSuggestion = widget.querySelector('.invest-suggestion'); 9911 const conversionEl = currency === 'dkk' ? document.getElementById('dkkConversion') : null; 9912 const logoEl = widget.querySelector('.gift-logo'); 9913 9914 // Conversion rates (approximate) 9915 const USD_TO_DKK = 7.0; // ~7 DKK per USD 9916 const USD_TO_XTZ = 0.85; // ~$1.18 per XTZ 9917 const USD_TO_ETH = 0.00029; // ~$3400 per ETH 9918 const USD_TO_BTC = 0.0000105; // ~$95000 per BTC 9919 9920 // Character wiggle animation - only shake at intensity 4, colors change earlier 9921 let wiggleAnimId = null; 9922 function startWiggle(intensity) { 9923 if (wiggleAnimId) return; 9924 const t0 = performance.now(); 9925 // Only shake at intensity 4 9926 const speed = intensity >= 4 ? 30 : 0; 9927 const amplitude = intensity >= 4 ? 3 : 0; 9928 9929 function animate() { 9930 const t = (performance.now() - t0) / 1000; 9931 const chars = amountEl.querySelectorAll('.wiggle-char'); 9932 chars.forEach((char, i) => { 9933 const hue = ((t * 60 * intensity) + i * 40) % 360; 9934 // Only translate at intensity 4 9935 if (intensity >= 4) { 9936 const x = Math.sin(t * speed + i * 2) * amplitude; 9937 const y = Math.cos(t * speed * 1.3 + i * 3) * amplitude; 9938 char.style.transform = `translate(${x}px, ${y}px)`; 9939 } else { 9940 char.style.transform = ''; 9941 } 9942 // Colors change at intensity 2+ 9943 if (intensity >= 2) { 9944 char.style.color = `hsl(${hue}, 100%, ${intensity >= 3 ? 75 : 60}%)`; 9945 } 9946 }); 9947 wiggleAnimId = requestAnimationFrame(animate); 9948 } 9949 animate(); 9950 } 9951 9952 function stopWiggle() { 9953 if (wiggleAnimId) { 9954 cancelAnimationFrame(wiggleAnimId); 9955 wiggleAnimId = null; 9956 } 9957 const chars = amountEl.querySelectorAll('.wiggle-char'); 9958 chars.forEach(char => { 9959 char.style.transform = ''; 9960 char.style.color = ''; 9961 }); 9962 } 9963 9964 function update() { 9965 const value = parseInt(slider.value); 9966 const suffix = isMonthly ? '/mo' : ''; 9967 const text = format(value) + suffix; 9968 9969 // Create wiggling characters 9970 // For DKK, '|' is a marker for the space before 'kr' - render as margin 9971 amountEl.innerHTML = text.split('').map(c => { 9972 if (c === '|') return '<span class="wiggle-char dkk-space"></span>'; 9973 if (c === ' ') return '<span class="wiggle-char">&nbsp;</span>'; 9974 return `<span class="wiggle-char">${c}</span>`; 9975 }).join(''); 9976 9977 const lang = document.body.classList.contains('lang-da') ? 'da' : 'en'; 9978 if (currency === 'dkk') { 9979 // Use innerHTML with thin space for DKK (YWFT renders regular space as +) 9980 const fmtVal = parseInt(slider.value).toLocaleString('en-US'); 9981 btn.innerHTML = lang === 'da' ? `Giv <span class="price">${fmtVal}&#8201;kr</span>` : `Give <span class="price">${fmtVal}&#8201;kr</span>`; 9982 } else if (isMonthly) { 9983 btn.innerHTML = lang === 'da' ? `Giv <span class="price">${format(value)}/md</span>` : `Give <span class="price">${format(value)}/mo</span>`; 9984 } else { 9985 btn.innerHTML = lang === 'da' ? `Giv <span class="price">${format(value)}</span>` : `Give <span class="price">${format(value)}</span>`; 9986 } 9987 9988 // Show invest suggestion for USD at $1000+ 9989 if (investSuggestion && currency === 'usd' && !isMonthly) { 9990 investSuggestion.style.display = value >= 1000 ? 'block' : 'none'; 9991 } 9992 9993 // Vegas intensity for USD (and DKK equivalent) - applies to both one-time and monthly 9994 if (currency === 'usd') { 9995 // Remove all intensity and effect classes 9996 amountEl.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4', 'flames', 'lightning'); 9997 logoEl?.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4'); 9998 slider.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4'); 9999 conversionEl?.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4'); 10000 10001 // Remove power-of-2 color classes 10002 const pow2Classes = ['pow2-1', 'pow2-2', 'pow2-4', 'pow2-8', 'pow2-16', 'pow2-32', 'pow2-64', 'pow2-128', 'pow2-256', 'pow2-512', 'pow2-1024', 'pow2-2048']; 10003 pow2Classes.forEach(c => amountEl.classList.remove(c)); 10004 10005 // Add appropriate intensity based on value (starts from $1) 10006 let intensity = 0; 10007 if (value >= 1024) { 10008 intensity = 4; 10009 } else if (value >= 256) { 10010 intensity = 3; 10011 } else if (value >= 64) { 10012 intensity = 2; 10013 } else if (value >= 1) { 10014 intensity = 1; 10015 } 10016 10017 if (intensity > 0) { 10018 amountEl.classList.add(`intensity-${intensity}`); 10019 logoEl?.classList.add(`intensity-${intensity}`); 10020 slider.classList.add(`intensity-${intensity}`); 10021 conversionEl?.classList.add(`intensity-${intensity}`); 10022 startWiggle(intensity); 10023 } else { 10024 stopWiggle(); 10025 } 10026 10027 // Add power-of-2 color class for exact powers of 2 10028 const powersOf2 = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048]; 10029 if (powersOf2.includes(value)) { 10030 amountEl.classList.add(`pow2-${value}`); 10031 } 10032 10033 // Special effects for max amounts 10034 if (isMonthly && value >= 500) { 10035 // Max monthly ($500/mo) - flames 10036 amountEl.classList.add('flames'); 10037 } else if (!isMonthly && value >= 2048) { 10038 // Max one-time ($2048) - flames + lightning 10039 amountEl.classList.add('flames', 'lightning'); 10040 } 10041 10042 } else if (currency === 'dkk') { 10043 // Vegas intensity for DKK (convert to USD equivalent for thresholds) 10044 const usdEquiv = value / USD_TO_DKK; 10045 amountEl.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4'); 10046 logoEl?.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4'); 10047 slider.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4'); 10048 conversionEl?.classList.remove('intensity-1', 'intensity-2', 'intensity-3', 'intensity-4'); 10049 10050 let intensity = 0; 10051 if (usdEquiv >= 1024) { 10052 intensity = 4; 10053 } else if (usdEquiv >= 256) { 10054 intensity = 3; 10055 } else if (usdEquiv >= 64) { 10056 intensity = 2; 10057 } else if (usdEquiv >= 1) { 10058 intensity = 1; 10059 } 10060 10061 if (intensity > 0) { 10062 amountEl.classList.add(`intensity-${intensity}`); 10063 logoEl?.classList.add(`intensity-${intensity}`); 10064 slider.classList.add(`intensity-${intensity}`); 10065 conversionEl?.classList.add(`intensity-${intensity}`); 10066 startWiggle(intensity); 10067 } else { 10068 stopWiggle(); 10069 } 10070 10071 // Show currency conversions for DKK -> USD only 10072 if (conversionEl) { 10073 const usd = Math.round(usdEquiv); 10074 conversionEl.innerHTML = `<span>≈ $${usd}</span>`; 10075 } 10076 } 10077 } 10078 10079 slider.addEventListener('input', () => { 10080 update(); 10081 // Sync other sliders of the same currency 10082 syncSliders(currency, slider); 10083 }); 10084 10085 update(); 10086 10087 // Register this slider for syncing (include all needed refs for monthly sync) 10088 if (sliderRegistry[currency]) { 10089 sliderRegistry[currency].push({ 10090 slider, 10091 update, 10092 widget, 10093 monthlyCheck, 10094 monthlyLabel, 10095 setIsMonthly: (val) => { isMonthly = val; } 10096 }); 10097 } 10098 10099 // Store isMonthly state on widget for button handler 10100 widget._isMonthly = () => isMonthly; 10101 }); 10102 10103 // Invest link click handler - scroll and blink 10104 document.querySelectorAll('.invest-link').forEach(link => { 10105 link.addEventListener('click', (e) => { 10106 e.preventDefault(); 10107 const investSection = document.getElementById('invest'); 10108 if (investSection) { 10109 // Scroll to invest section 10110 investSection.scrollIntoView({ behavior: 'smooth', block: 'center' }); 10111 10112 // Add blinking animation after scroll completes 10113 setTimeout(() => { 10114 investSection.classList.add('blinking'); 10115 // Remove class after animation completes 10116 setTimeout(() => { 10117 investSection.classList.remove('blinking'); 10118 }, 1800); // 3 blinks × 0.6s 10119 }, 400); // Wait for scroll to start 10120 } 10121 }); 10122 }); 10123 10124 // Cancel subscription link handler 10125 document.querySelectorAll('.cancel-subscription-link').forEach(link => { 10126 link.addEventListener('click', async (e) => { 10127 e.preventDefault(); 10128 10129 const prompts = { 10130 en: { 10131 loginRequired: 'Please log in to manage your subscription.', 10132 loading: 'Loading...', 10133 error: 'Could not find a subscription for your account. Please try again or contact support.', 10134 errorGeneric: 'Something went wrong. Please try again.' 10135 }, 10136 da: { 10137 loginRequired: 'Log ind for at administrere dit abonnement.', 10138 loading: 'Indlæser...', 10139 error: 'Kunne ikke finde et abonnement for din konto. Prøv igen eller kontakt support.', 10140 errorGeneric: 'Noget gik galt. Prøv igen.' 10141 }, 10142 de: { 10143 loginRequired: 'Bitte melden Sie sich an, um Ihr Abonnement zu verwalten:', 10144 loading: 'Laden...', 10145 error: 'Kein Abonnement für Ihr Konto gefunden. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support.', 10146 errorGeneric: 'Etwas ist schief gelaufen. Bitte versuchen Sie es erneut.' 10147 }, 10148 zh: { 10149 loginRequired: '请先登录以管理订阅。', 10150 loading: '加载中...', 10151 error: '找不到您账户的订阅。请重试或联系支持。', 10152 errorGeneric: '出了点问题。请重试。' 10153 }, 10154 es: { 10155 loginRequired: 'Inicia sesión para gestionar tu suscripción.', 10156 loading: 'Cargando...', 10157 error: 'No se encontró una suscripción para tu cuenta. Intenta de nuevo o contacta soporte.', 10158 errorGeneric: 'Algo salió mal. Intenta de nuevo.' 10159 } 10160 }; 10161 10162 const t = prompts[currentLang] || prompts.en; 10163 const token = await getAuthToken(); 10164 if (!token || !acUser?.email) { 10165 alert(t.loginRequired); 10166 await footerLogin(); 10167 return; 10168 } 10169 10170 link.style.pointerEvents = 'none'; 10171 const origText = link.innerHTML; 10172 link.textContent = t.loading; 10173 10174 try { 10175 const res = await fetch('/api/give-portal', { 10176 method: 'POST', 10177 headers: { 10178 'Content-Type': 'application/json', 10179 'Authorization': `Bearer ${token}` 10180 }, 10181 body: JSON.stringify({ email: acUser.email }) 10182 }); 10183 const data = await res.json(); 10184 10185 if (data.url) { 10186 window.location.href = data.url; 10187 } else { 10188 alert(data.error || t.error); 10189 link.innerHTML = origText; 10190 link.style.pointerEvents = ''; 10191 } 10192 } catch (err) { 10193 console.error('Portal error:', err); 10194 alert(t.errorGeneric); 10195 link.innerHTML = origText; 10196 link.style.pointerEvents = ''; 10197 } 10198 }); 10199 }); 10200 10201 // Direct checkout (no modal) 10202 async function goToCheckout(amount, currency, isMonthly, btn) { 10203 const origText = btn.textContent; 10204 btn.disabled = true; 10205 btn.textContent = currentLang === 'da' ? 'Giver...' : 'Giving...'; 10206 10207 try { 10208 const payload = { amount: amount * 100, currency, recurring: isMonthly }; 10209 // Include email if available (from URL prefill or logged-in user) 10210 const email = prefillEmail || acUser?.email; 10211 if (email) payload.email = email; 10212 10213 const res = await fetch('https://aesthetic.computer/api/give', { 10214 method: 'POST', 10215 headers: { 'Content-Type': 'application/json' }, 10216 body: JSON.stringify(payload) 10217 }); 10218 const data = await res.json(); 10219 if (data.url) window.location.href = data.url; 10220 else throw new Error(data.error || 'No URL returned'); 10221 } catch (err) { 10222 console.error('Give error:', err); 10223 btn.disabled = false; 10224 btn.textContent = origText; 10225 alert(currentLang === 'da' ? 'Noget gik galt' : 'Something went wrong'); 10226 } 10227 } 10228 10229 document.querySelectorAll('.gift-widget:not(.crypto):not(.paypal) .gift-btn').forEach(btn => { 10230 btn.onclick = () => { 10231 const currency = btn.dataset.currency; 10232 const widget = btn.closest('.gift-widget'); 10233 const slider = widget.querySelector('input[type="range"]'); 10234 const value = parseInt(slider.value); 10235 const isMonthly = widget._isMonthly ? widget._isMonthly() : false; 10236 10237 goToCheckout(value, currency, isMonthly, btn); 10238 }; 10239 }); 10240 10241 function copyAddress(el) { 10242 const text = el.dataset.copy; 10243 navigator.clipboard.writeText(text); 10244 el.classList.add('copied'); 10245 10246 // Update hint text to "Copied!" 10247 const hints = el.querySelectorAll('.crypto-copy-hint'); 10248 hints.forEach(hint => { 10249 const origText = hint.textContent; 10250 hint.textContent = '✓ ' + (hint.dataset.lang === 'da' ? 'Kopieret!' : 'Copied!'); 10251 setTimeout(() => hint.textContent = origText, 1200); 10252 }); 10253 10254 setTimeout(() => el.classList.remove('copied'), 1200); 10255 } 10256 10257 // Make crypto addresses/labels copyable 10258 document.querySelectorAll('.copyable').forEach(el => { 10259 el.addEventListener('click', () => copyAddress(el)); 10260 }); 10261 10262 // Crypto prices and balances 10263 let cryptoPrices = { xtz: null, eth: null, btc: null }; 10264 let walletBalances = { xtz: null, eth: null, btc: null }; 10265 10266 const WALLET_ADDRESSES = { 10267 btc: 'bc1q699gnqr92gvgv62nla82typpj3wls5a8xf59as', 10268 eth: '0x1D64E3eFa983D945cBFe29Ad5b3C8ABB53Aef023', 10269 xtz: 'tz1gkf8EexComFBJvjtT1zdsisdah791KwBE' 10270 }; 10271 10272 async function fetchCryptoPrices() { 10273 try { 10274 const res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=tezos,ethereum,bitcoin&vs_currencies=usd'); 10275 const data = await res.json(); 10276 cryptoPrices.xtz = data.tezos?.usd || null; 10277 cryptoPrices.eth = data.ethereum?.usd || null; 10278 cryptoPrices.btc = data.bitcoin?.usd || null; 10279 updateAllCryptoDisplays(); 10280 updateBalanceDisplays(); 10281 } catch (e) { 10282 console.log('Could not fetch crypto prices'); 10283 } 10284 } 10285 10286 // Fetch Bitcoin balance via Blockstream API 10287 async function fetchBtcBalance() { 10288 try { 10289 const res = await fetch(`https://blockstream.info/api/address/${WALLET_ADDRESSES.btc}`); 10290 const data = await res.json(); 10291 // Balance is in satoshis, convert to BTC 10292 const funded = data.chain_stats?.funded_txo_sum || 0; 10293 const spent = data.chain_stats?.spent_txo_sum || 0; 10294 walletBalances.btc = (funded - spent) / 100000000; 10295 updateBalanceDisplays(); 10296 } catch (e) { 10297 console.log('Could not fetch BTC balance'); 10298 document.getElementById('balance-btc').innerHTML = '<span class="loading">—</span>'; 10299 } 10300 } 10301 10302 // Fetch Ethereum balance via public RPC 10303 async function fetchEthBalance() { 10304 try { 10305 const res = await fetch('https://eth.llamarpc.com', { 10306 method: 'POST', 10307 headers: { 'Content-Type': 'application/json' }, 10308 body: JSON.stringify({ 10309 jsonrpc: '2.0', 10310 method: 'eth_getBalance', 10311 params: [WALLET_ADDRESSES.eth, 'latest'], 10312 id: 1 10313 }) 10314 }); 10315 const data = await res.json(); 10316 // Convert from wei (hex) to ETH 10317 walletBalances.eth = parseInt(data.result, 16) / 1e18; 10318 updateBalanceDisplays(); 10319 } catch (e) { 10320 console.log('Could not fetch ETH balance'); 10321 document.getElementById('balance-eth').innerHTML = '<span class="loading">—</span>'; 10322 } 10323 } 10324 10325 // Fetch Tezos balance via TzKT API 10326 async function fetchXtzBalance() { 10327 try { 10328 const res = await fetch(`https://api.tzkt.io/v1/accounts/${WALLET_ADDRESSES.xtz}/balance`); 10329 const balance = await res.json(); 10330 // Balance is in mutez, convert to XTZ 10331 walletBalances.xtz = balance / 1000000; 10332 updateBalanceDisplays(); 10333 } catch (e) { 10334 console.log('Could not fetch XTZ balance'); 10335 document.getElementById('balance-xtz').innerHTML = '<span class="loading">—</span>'; 10336 } 10337 } 10338 10339 function formatBalance(amount, decimals = 4) { 10340 if (amount === null || amount === undefined) return null; 10341 if (amount < 0.0001) return '< 0.0001'; 10342 return amount.toFixed(decimals).replace(/\.?0+$/, ''); 10343 } 10344 10345 function updateBalanceDisplays() { 10346 // Bitcoin 10347 const btcEl = document.getElementById('balance-btc'); 10348 if (btcEl && walletBalances.btc !== null) { 10349 const btcFormatted = formatBalance(walletBalances.btc, 6); 10350 let html = `${btcFormatted} BTC`; 10351 if (cryptoPrices.btc) { 10352 const usdValue = (walletBalances.btc * cryptoPrices.btc).toFixed(2); 10353 html += ` <span class="usd-equiv">≈ $${usdValue}</span>`; 10354 } 10355 btcEl.innerHTML = html; 10356 } 10357 10358 // Ethereum 10359 const ethEl = document.getElementById('balance-eth'); 10360 if (ethEl && walletBalances.eth !== null) { 10361 const ethFormatted = formatBalance(walletBalances.eth, 4); 10362 let html = `${ethFormatted} ETH`; 10363 if (cryptoPrices.eth) { 10364 const usdValue = (walletBalances.eth * cryptoPrices.eth).toFixed(2); 10365 html += ` <span class="usd-equiv">≈ $${usdValue}</span>`; 10366 } 10367 ethEl.innerHTML = html; 10368 } 10369 10370 // Tezos 10371 const xtzEl = document.getElementById('balance-xtz'); 10372 if (xtzEl && walletBalances.xtz !== null) { 10373 const xtzFormatted = formatBalance(walletBalances.xtz, 2); 10374 let html = `${xtzFormatted} XTZ`; 10375 if (cryptoPrices.xtz) { 10376 const usdValue = (walletBalances.xtz * cryptoPrices.xtz).toFixed(2); 10377 html += ` <span class="usd-equiv">≈ $${usdValue}</span>`; 10378 } 10379 xtzEl.innerHTML = html; 10380 } 10381 } 10382 10383 function updateAllCryptoDisplays() { 10384 document.querySelectorAll('.crypto-slider').forEach(slider => { 10385 const currency = slider.dataset.currency; 10386 const widget = slider.closest('.gift-widget'); 10387 updateCryptoDisplay(widget, slider, currency); 10388 }); 10389 } 10390 10391 function updateCryptoDisplay(widget, slider, currency) { 10392 const amountEl = widget.querySelector('.gift-amount'); 10393 const sendBtn = widget.querySelector('.crypto-send'); 10394 const usdEl = widget.querySelector('.crypto-usd'); 10395 10396 const value = currency === 'eth' ? parseFloat(slider.value).toFixed(2) : parseInt(slider.value); 10397 const symbol = currency === 'eth' ? 'Ξ' : 'ꜩ'; 10398 const emoji = currency === 'eth' ? '💎' : '🔮'; 10399 10400 amountEl.textContent = `${emoji} ${value} ${symbol}`; 10401 sendBtn.textContent = `Send ${value} ${symbol}`; 10402 10403 const price = cryptoPrices[currency]; 10404 if (price && usdEl) { 10405 const usdValue = (parseFloat(value) * price).toFixed(2); 10406 usdEl.textContent = `$${usdValue}`; 10407 } 10408 } 10409 10410 // Crypto slider functionality 10411 document.querySelectorAll('.crypto-slider').forEach(slider => { 10412 const widget = slider.closest('.gift-widget'); 10413 const currency = slider.dataset.currency; 10414 10415 slider.addEventListener('input', () => updateCryptoDisplay(widget, slider, currency)); 10416 updateCryptoDisplay(widget, slider, currency); 10417 }); 10418 10419 // Fetch prices and balances on load 10420 fetchCryptoPrices(); 10421 fetchBtcBalance(); 10422 fetchEthBalance(); 10423 fetchXtzBalance(); 10424 10425 // Tezos wallet connect (Beacon) 10426 async function sendTezos(amount) { 10427 const btn = document.querySelector('[data-currency="xtz"].crypto-send'); 10428 const origText = btn.textContent; 10429 10430 try { 10431 btn.textContent = currentLang === 'da' ? 'Forbinder...' : 'Connecting...'; 10432 btn.disabled = true; 10433 10434 // Use Beacon SDK via script tag approach 10435 if (!window.beacon) { 10436 // Load Beacon SDK 10437 await new Promise((resolve, reject) => { 10438 const script = document.createElement('script'); 10439 script.src = 'https://unpkg.com/@airgap/beacon-sdk@4.0.12/dist/walletbeacon.min.js'; 10440 script.onload = resolve; 10441 script.onerror = reject; 10442 document.head.appendChild(script); 10443 }); 10444 } 10445 10446 const client = new window.beacon.DAppClient({ name: 'Aesthetic Computer Gift' }); 10447 10448 btn.textContent = currentLang === 'da' ? 'Vælg tegnebog...' : 'Select wallet...'; 10449 await client.requestPermissions(); 10450 10451 btn.textContent = currentLang === 'da' ? 'Bekræft i tegnebog...' : 'Confirm in wallet...'; 10452 const response = await client.requestOperation({ 10453 operationDetails: [{ 10454 kind: 'transaction', 10455 destination: 'tz1WqvhbfEwfqAzMtEGxc7x6tpSqRMCNKPCT', 10456 amount: String(Math.floor(amount * 1000000)), // mutez 10457 fee: '1500', // suggested fee in mutez (~0.0015 XTZ) 10458 gasLimit: '10600', // typical gas for simple transfer 10459 storageLimit: '300' // typical storage for simple transfer 10460 }] 10461 }); 10462 10463 btn.textContent = currentLang === 'da' ? '✓ Sendt!' : '✓ Sent!'; 10464 console.log('TX:', response.transactionHash); 10465 setTimeout(() => { 10466 btn.textContent = origText; 10467 btn.disabled = false; 10468 }, 3000); 10469 10470 } catch (err) { 10471 console.error('Tezos error:', err); 10472 btn.textContent = err.message?.includes('Aborted') 10473 ? (currentLang === 'da' ? 'Annulleret' : 'Cancelled') 10474 : (currentLang === 'da' ? 'Fejl - prøv igen' : 'Error - try again'); 10475 setTimeout(() => { 10476 btn.textContent = origText; 10477 btn.disabled = false; 10478 }, 2000); 10479 } 10480 } 10481 10482 // Ethereum wallet connect 10483 async function sendEthereum(amount) { 10484 const btn = document.querySelector('[data-currency="eth"].crypto-send'); 10485 const origText = btn.textContent; 10486 10487 try { 10488 btn.textContent = currentLang === 'da' ? 'Forbinder...' : 'Connecting...'; 10489 btn.disabled = true; 10490 10491 if (!window.ethereum) { 10492 alert(currentLang === 'da' ? 'Installér MetaMask eller en anden Ethereum-tegnebog' : 'Please install MetaMask or another Ethereum wallet'); 10493 throw new Error('No wallet'); 10494 } 10495 10496 btn.textContent = currentLang === 'da' ? 'Godkend forbindelse...' : 'Approve connection...'; 10497 const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); 10498 10499 if (!accounts.length) throw new Error('No account'); 10500 10501 const amountWei = '0x' + (BigInt(Math.floor(amount * 1e18))).toString(16); 10502 10503 btn.textContent = currentLang === 'da' ? 'Bekræft i tegnebog...' : 'Confirm in wallet...'; 10504 const txHash = await window.ethereum.request({ 10505 method: 'eth_sendTransaction', 10506 params: [{ 10507 from: accounts[0], 10508 to: '0x1D64E3eFa983D945cBFe29Ad5b3C8ABB53Aef023', 10509 value: amountWei 10510 }] 10511 }); 10512 10513 btn.textContent = currentLang === 'da' ? '✓ Sendt!' : '✓ Sent!'; 10514 console.log('TX:', txHash); 10515 setTimeout(() => { 10516 btn.textContent = origText; 10517 btn.disabled = false; 10518 }, 3000); 10519 10520 } catch (err) { 10521 console.error('Ethereum error:', err); 10522 if (err.message !== 'No wallet') { 10523 btn.textContent = currentLang === 'da' ? 'Annulleret' : 'Cancelled'; 10524 } 10525 setTimeout(() => { 10526 btn.textContent = origText; 10527 btn.disabled = false; 10528 }, 2000); 10529 } 10530 } 10531 10532 // Wire up crypto send buttons 10533 document.querySelectorAll('.crypto-send').forEach(btn => { 10534 btn.addEventListener('click', () => { 10535 const widget = btn.closest('.gift-widget'); 10536 const slider = widget.querySelector('.crypto-slider'); 10537 const currency = slider.dataset.currency; 10538 const value = currency === 'eth' ? parseFloat(slider.value) : parseInt(slider.value); 10539 10540 if (currency === 'xtz') { 10541 sendTezos(value); 10542 } else if (currency === 'eth') { 10543 sendEthereum(value); 10544 } 10545 }); 10546 }); 10547 10548 // Live reload in dev mode 10549 if (location.hostname === 'localhost' || location.hostname.includes('local')) { 10550 const wsUrl = location.protocol === 'https:' 10551 ? 'wss://localhost:8889' 10552 : 'ws://localhost:8889'; 10553 10554 function connectWS() { 10555 const ws = new WebSocket(wsUrl); 10556 ws.onopen = () => console.log('🔄 Live reload connected'); 10557 ws.onmessage = (e) => { 10558 try { 10559 const msg = JSON.parse(e.data); 10560 if (msg.type === 'reload' && msg.content?.piece === '*refresh*') { 10561 console.log('🔄 Reloading...'); 10562 location.reload(); 10563 } 10564 } catch {} 10565 }; 10566 ws.onclose = () => setTimeout(connectWS, 2000); 10567 ws.onerror = () => ws.close(); 10568 } 10569 connectWS(); 10570 } 10571 10572 // 🎬 Synchronized Panel Controller 10573 // All rotating panels wait until everything is preloaded, then start together 10574 const SYNC_INTERVAL = 8000; // 8 seconds - all panels use this 10575 const panelSync = { 10576 ready: { shop: false, kidlisp: false, chat: false, apps: false }, 10577 started: false, 10578 startersRun: false, // Track if starters have been executed 10579 starters: [], 10580 10581 markReady(panel) { 10582 this.ready[panel] = true; 10583 this.checkAllReady(); 10584 }, 10585 10586 registerStarter(fn) { 10587 this.starters.push(fn); 10588 // Only run immediately if starters have ALREADY been executed 10589 if (this.startersRun) fn(); 10590 }, 10591 10592 checkAllReady() { 10593 if (this.started) return; 10594 const allReady = Object.values(this.ready).every(v => v); 10595 if (allReady) { 10596 this.started = true; 10597 // Small delay to ensure UI is settled, then start all at once 10598 setTimeout(() => { 10599 this.startersRun = true; 10600 this.starters.forEach(fn => fn()); 10601 }, 500); 10602 } 10603 } 10604 }; 10605 10606 if (!SHOP_PANEL_ENABLED) { 10607 document.getElementById('shopSection')?.setAttribute('hidden', ''); 10608 panelSync.markReady('shop'); 10609 } 10610 10611 // Fetch shop products - rotating display with image cycling 10612 let shopProducts = []; 10613 let shopIndex = 0; 10614 let shopImageIndex = 0; 10615 let shopProgressAnim = null; 10616 let shopImageInterval = null; 10617 let shopLoadAttempts = 0; 10618 let shopLoaded = false; 10619 const SHOP_IMAGE_INTERVAL = 2000; // 2 seconds between images within same product 10620 const SHOP_PRODUCT_INTERVAL = 8000; // 8 seconds (synced with other panels) 10621 const SHOP_MAX_RETRIES = 5; 10622 const SHOP_RETRY_DELAYS = [500, 1000, 2000, 4000, 8000]; // Exponential backoff 10623 10624 function animateShopProgress(duration) { 10625 const progressBar = document.getElementById('shopAutoProgress'); 10626 if (!progressBar) return; 10627 10628 let startTime = null; 10629 10630 function step(timestamp) { 10631 if (!startTime) startTime = timestamp; 10632 const elapsed = timestamp - startTime; 10633 const pct = Math.min((elapsed / duration) * 100, 100); 10634 progressBar.style.width = pct + '%'; 10635 10636 if (elapsed < duration) { 10637 shopProgressAnim = requestAnimationFrame(step); 10638 } 10639 } 10640 10641 if (shopProgressAnim) cancelAnimationFrame(shopProgressAnim); 10642 progressBar.style.width = '0%'; 10643 shopProgressAnim = requestAnimationFrame(step); 10644 } 10645 10646 function renderShopMaintenance(container, detail = '') { 10647 if (!SHOP_PANEL_ENABLED) return; 10648 if (SERVER_SUSPENDED) { 10649 renderSuspended(container, 'card'); 10650 return; 10651 } 10652 container.innerHTML = ` 10653 <div style="display:flex;align-items:center;justify-content:center;height:100%;min-height:300px;"> 10654 <div style="text-align:center;color:#888;"> 10655 <div style="font-size:120px;line-height:1;color:#ff4444;">✕</div> 10656 <div style="font-size:14px;margin-top:8px;">closed for maintenance</div> 10657 </div> 10658 </div> 10659 `; 10660 } 10661 10662 async function loadShopProducts(isRetry = false) { 10663 if (!SHOP_PANEL_ENABLED) return; 10664 if (shopLoaded) return; // Already loaded successfully 10665 10666 const container = document.getElementById('shopProducts'); 10667 shopLoadAttempts++; 10668 10669 try { 10670 const controller = new AbortController(); 10671 const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout 10672 10673 const res = await fetch('https://aesthetic.computer/api/shop', { 10674 signal: controller.signal 10675 }); 10676 clearTimeout(timeoutId); 10677 10678 if (!res.ok) { 10679 const retryAfterHeader = res.headers?.get?.('Retry-After'); 10680 const retryAfterSeconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : null; 10681 const err = new Error('Shop API HTTP ' + res.status); 10682 err.status = res.status; 10683 err.retryAfterMs = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds * 1000 : null; 10684 throw err; 10685 } 10686 10687 const data = await res.json(); 10688 10689 if (!data.products?.length) { 10690 renderShopMaintenance(container); 10691 panelSync.markReady('shop'); 10692 return; 10693 } 10694 10695 shopProducts = data.products; 10696 shopLoaded = true; 10697 showShopProduct(0, true); // Initial load with isInitial=true 10698 // Mark ready and register starter (don't start cycling yet) 10699 panelSync.markReady('shop'); 10700 panelSync.registerStarter(() => startShopCycle()); 10701 } catch (e) { 10702 // Shop unavailable - show closed message 10703 renderShopMaintenance(container); 10704 panelSync.markReady('shop'); 10705 } 10706 } 10707 10708 // Also retry when shop section becomes visible (for mobile where it may be off-screen initially) 10709 function setupShopVisibilityRetry() { 10710 if (!SHOP_PANEL_ENABLED) return; 10711 const shopSection = document.querySelector('.shop-section'); 10712 if (!shopSection || shopLoaded) return; 10713 10714 const observer = new IntersectionObserver((entries) => { 10715 entries.forEach(entry => { 10716 if (entry.isIntersecting && !shopLoaded && shopLoadAttempts > 0) { 10717 // Section became visible and we haven't loaded yet - retry 10718 shopLoadAttempts = 0; // Reset attempts for visibility-triggered retry 10719 loadShopProducts(true); 10720 } 10721 }); 10722 }, { threshold: 0.1 }); 10723 10724 observer.observe(shopSection); 10725 } 10726 10727 function startShopCycle() { 10728 if (!SHOP_PANEL_ENABLED) return; 10729 // Clear any existing intervals 10730 if (shopImageInterval) clearInterval(shopImageInterval); 10731 10732 const p = shopProducts[shopIndex]; 10733 if (!p) return; 10734 10735 const images = p.images || [p.imageUrl].filter(Boolean); 10736 shopImageIndex = 0; 10737 10738 if (images.length > 1) { 10739 // Multiple images: cycle through them, then advance product 10740 const totalDuration = images.length * SHOP_IMAGE_INTERVAL; 10741 animateShopProgress(totalDuration); 10742 10743 shopImageInterval = setInterval(() => { 10744 shopImageIndex++; 10745 if (shopImageIndex >= images.length) { 10746 // All images shown, advance to next product 10747 clearInterval(shopImageInterval); 10748 shopIndex = (shopIndex + 1) % shopProducts.length; 10749 shopImageIndex = 0; 10750 showShopProduct(shopIndex); 10751 startShopCycle(); 10752 } else { 10753 // Show next image of same product 10754 updateShopImage(shopImageIndex); 10755 } 10756 }, SHOP_IMAGE_INTERVAL); 10757 } else { 10758 // Single image: just use standard interval 10759 animateShopProgress(SHOP_PRODUCT_INTERVAL); 10760 10761 shopImageInterval = setInterval(() => { 10762 clearInterval(shopImageInterval); 10763 shopIndex = (shopIndex + 1) % shopProducts.length; 10764 shopImageIndex = 0; 10765 showShopProduct(shopIndex); 10766 startShopCycle(); 10767 }, SHOP_PRODUCT_INTERVAL); 10768 } 10769 } 10770 10771 function updateShopImage(imgIndex) { 10772 const p = shopProducts[shopIndex]; 10773 if (!p) return; 10774 10775 const images = p.images || [p.imageUrl].filter(Boolean); 10776 if (!images.length) return; 10777 10778 // Find the currently visible layer's image wrap 10779 const visibleLayer = document.querySelector('.shop-product-layer.visible'); 10780 const wrap = visibleLayer ? visibleLayer.querySelector('.shop-product-img-wrap') : document.querySelector('.shop-product-img-wrap'); 10781 if (!wrap) return; 10782 10783 const currentImg = wrap.querySelector('img'); 10784 if (!currentImg) return; 10785 10786 // Preload the new image 10787 const newImg = new Image(); 10788 newImg.src = images[imgIndex]; 10789 newImg.alt = currentImg.alt; 10790 newImg.className = 'shop-img-front'; 10791 newImg.style.opacity = '0'; 10792 10793 newImg.onload = () => { 10794 // Add new image on top 10795 wrap.insertBefore(newImg, wrap.firstChild); 10796 10797 // Trigger reflow then fade in 10798 newImg.offsetHeight; 10799 newImg.style.opacity = '1'; 10800 10801 // Remove old image after transition 10802 setTimeout(() => { 10803 if (currentImg && currentImg.parentNode) { 10804 currentImg.remove(); 10805 } 10806 }, 450); 10807 }; 10808 } 10809 10810 function createProductHTML(p) { 10811 // Get all images 10812 const images = p.images || [p.imageUrl].filter(Boolean); 10813 const currentImage = images.length > 0 ? images[0] : ''; 10814 10815 // Extract vendor from product data if available 10816 const vendor = p.vendor || ''; 10817 const vendorHtml = vendor ? `<span class="shop-product-vendor" style="color:#ff6b9d">${vendor.startsWith('@') ? vendor : '@' + vendor}</span>` : ''; 10818 10819 // Process description - preserve blank lines as half-height spacers, wrap in inner div for scroll animation 10820 const desc = (p.description || '').trim(); 10821 const descContent = escapeHtml(desc).replace(/\n\n+/g, '<br><span style="display:block;height:0.4em"></span>').replace(/\n/g, '<br>'); 10822 const descHtml = desc ? `<div class="shop-product-desc"><div class="shop-product-desc-inner">${descContent}</div></div>` : ''; 10823 10824 // Strip currency code from price (keep just symbol + number) 10825 const priceDisplay = (p.price || '').replace(/\s*USD$/i, '').replace(/\s*CAD$/i, '').replace(/\s*EUR$/i, '').trim(); 10826 10827 return ` 10828 <a href="${p.shopUrl}" class="shop-product" target="_blank"> 10829 <div class="shop-product-img-wrap"> 10830 <img src="${currentImage}" alt="${escapeHtml(p.title)}" loading="lazy" class="shop-img-front"> 10831 </div> 10832 <div class="shop-product-overlay"> 10833 <span class="shop-product-title-row"> 10834 <span class="shop-product-title">${escapeHtml(p.title)}</span> 10835 ${vendorHtml} 10836 </span> 10837 ${descHtml} 10838 </div> 10839 ${p.available 10840 ? `<div class="shop-product-price">${priceDisplay}</div>` 10841 : `<div class="shop-product-sold">SOLD</div>` 10842 } 10843 </a> 10844 `; 10845 } 10846 10847 let shopCurrentLayer = 'a'; // Track which layer is currently visible 10848 10849 function showShopProduct(index, isInitial = false) { 10850 const container = document.getElementById('shopProducts'); 10851 const p = shopProducts[index]; 10852 if (!p) return; 10853 10854 const productHTML = createProductHTML(p); 10855 10856 // Initial load - set up dual layer structure 10857 if (isInitial || !container.querySelector('.shop-product-layer')) { 10858 container.innerHTML = ` 10859 <div class="shop-product-layer layer-back visible" data-layer="a">${productHTML}</div> 10860 <div class="shop-product-layer layer-front entering" data-layer="b"></div> 10861 `; 10862 shopCurrentLayer = 'a'; 10863 // Preload next product image 10864 preloadNextShopProduct(index); 10865 return; 10866 } 10867 10868 // Crossfade transition 10869 const layerA = container.querySelector('[data-layer="a"]'); 10870 const layerB = container.querySelector('[data-layer="b"]'); 10871 10872 if (shopCurrentLayer === 'a') { 10873 // A is visible, prepare B and fade it in 10874 layerB.innerHTML = productHTML; 10875 layerB.classList.remove('entering', 'exiting'); 10876 layerB.classList.add('layer-front'); 10877 layerA.classList.remove('layer-front'); 10878 layerA.classList.add('layer-back'); 10879 10880 // Trigger reflow 10881 layerB.offsetHeight; 10882 10883 // Start transition 10884 layerB.classList.add('visible'); 10885 layerA.classList.remove('visible'); 10886 layerA.classList.add('exiting'); 10887 10888 shopCurrentLayer = 'b'; 10889 } else { 10890 // B is visible, prepare A and fade it in 10891 layerA.innerHTML = productHTML; 10892 layerA.classList.remove('entering', 'exiting'); 10893 layerA.classList.add('layer-front'); 10894 layerB.classList.remove('layer-front'); 10895 layerB.classList.add('layer-back'); 10896 10897 // Trigger reflow 10898 layerA.offsetHeight; 10899 10900 // Start transition 10901 layerA.classList.add('visible'); 10902 layerB.classList.remove('visible'); 10903 layerB.classList.add('exiting'); 10904 10905 shopCurrentLayer = 'a'; 10906 } 10907 10908 // Preload next product image 10909 preloadNextShopProduct(index); 10910 } 10911 10912 function preloadNextShopProduct(currentIndex) { 10913 const nextIndex = (currentIndex + 1) % shopProducts.length; 10914 const nextProduct = shopProducts[nextIndex]; 10915 if (!nextProduct) return; 10916 10917 const images = nextProduct.images || [nextProduct.imageUrl].filter(Boolean); 10918 if (images.length > 0) { 10919 const preloadImg = new Image(); 10920 preloadImg.src = images[0]; 10921 } 10922 } 10923 if (SHOP_PANEL_ENABLED) { 10924 setTimeout(loadShopProducts, 100); 10925 setTimeout(setupShopVisibilityRetry, 200); // Set up visibility observer 10926 } 10927 10928 // Load stats from metrics API 10929 async function loadStats() { 10930 try { 10931 const res = await fetch('https://aesthetic.computer/api/metrics'); 10932 const data = await res.json(); 10933 10934 const fmt = n => n?.toLocaleString() || '—'; 10935 10936 // Update stat values 10937 const handlesEl = document.querySelector('#stat-handles .stat-value'); 10938 const paintingsEl = document.querySelector('#stat-paintings .stat-value'); 10939 const moodsEl = document.querySelector('#stat-moods .stat-value'); 10940 const commandsEl = document.querySelector('#stat-commands .stat-value'); 10941 const messagesEl = document.querySelector('#stat-messages .stat-value'); 10942 10943 if (handlesEl) handlesEl.textContent = fmt(data.handles); 10944 if (paintingsEl) paintingsEl.textContent = fmt(data.paintings); 10945 if (moodsEl) moodsEl.textContent = fmt(data.moods); 10946 // Commands = pieces + prompts (from docs) 10947 const commandCount = (data.pieces || 0) + 107; // 107 prompts from docs.js 10948 if (commandsEl) commandsEl.textContent = fmt(commandCount); 10949 // Messages = chatMessages (laer-klokken + chat combined) 10950 if (messagesEl) messagesEl.textContent = fmt(data.chatMessages); 10951 10952 // Store raw values for sorting (kidlisp loaded separately) 10953 window._statValues = { 10954 'stat-handles': data.handles || 0, 10955 'stat-paintings': data.paintings || 0, 10956 'stat-moods': data.moods || 0, 10957 'stat-commands': commandCount, 10958 'stat-messages': data.chatMessages || 0 10959 }; 10960 sortStatBoxes(); 10961 10962 } catch (e) { 10963 console.warn('Could not load stats:', e); 10964 if (SERVER_SUSPENDED) { 10965 // Show offline indicator on stat values 10966 document.querySelectorAll('.stat-value').forEach(el => { 10967 if (el.textContent === '—' || el.textContent === '...') { 10968 el.textContent = '—'; 10969 el.style.opacity = '0.4'; 10970 } 10971 }); 10972 } 10973 } 10974 } 10975 10976 // Sort stat boxes by value (highest to lowest) 10977 function sortStatBoxes() { 10978 const section = document.querySelector('.stats-section'); 10979 if (!section || !window._statValues) return; 10980 10981 const items = Array.from(section.querySelectorAll('.stat-item')); 10982 items.sort((a, b) => { 10983 const valA = window._statValues[a.id] || 0; 10984 const valB = window._statValues[b.id] || 0; 10985 return valB - valA; // Descending (highest first) 10986 }); 10987 10988 // Reorder DOM elements 10989 items.forEach(item => section.appendChild(item)); 10990 } 10991 10992 setTimeout(loadStats, 200); 10993 10994 // KidLisp count - loaded after apiBase is defined (see below) 10995 10996 // Format detailed timestamp for gives 10997 // Returns { time: string, useOn: boolean } - useOn=true for date-based times 10998 function formatGiveTime(timestamp) { 10999 if (!timestamp || typeof timestamp !== 'number') return { time: '', useOn: false }; 11000 const date = new Date(timestamp); 11001 const now = new Date(); 11002 const isToday = date.toDateString() === now.toDateString(); 11003 const isYesterday = new Date(now - 86400000).toDateString() === date.toDateString(); 11004 11005 // Localized time format 11006 const locales = { en: 'en-US', da: 'da-DK', de: 'de-DE', es: 'es-ES', zh: 'zh-CN' }; 11007 const locale = locales[currentLang] || 'en-US'; 11008 11009 const time = date.toLocaleTimeString(locale, { 11010 hour: 'numeric', 11011 minute: '2-digit', 11012 hour12: currentLang === 'en' || currentLang === 'es' 11013 }).toLowerCase(); 11014 11015 // Translated "today at" and "yesterday at" 11016 const timeWords = { 11017 en: { today: 'today at', yesterday: 'yesterday at', at: 'at' }, 11018 da: { today: 'i dag kl.', yesterday: 'i går kl.', at: 'kl.' }, 11019 de: { today: 'heute um', yesterday: 'gestern um', at: 'um' }, 11020 es: { today: 'hoy a las', yesterday: 'ayer a las', at: 'a las' }, 11021 zh: { today: '今天', yesterday: '昨天', at: '' } 11022 }; 11023 const tw = timeWords[currentLang] || timeWords.en; 11024 11025 if (isToday) return { time: `${tw.today} ${time}`, useOn: false }; 11026 if (isYesterday) return { time: `${tw.yesterday} ${time}`, useOn: false }; 11027 11028 const diff = now - date; 11029 const days = Math.floor(diff / 86400000); 11030 if (days < 7) { 11031 const dayName = date.toLocaleDateString(locale, { weekday: 'short' }); 11032 return { time: tw.at ? `${dayName} ${tw.at} ${time}` : `${dayName} ${time}`, useOn: true }; 11033 } 11034 11035 const dateStr = date.toLocaleDateString(locale, { 11036 month: 'short', 11037 day: 'numeric', 11038 year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 11039 }); 11040 return { time: dateStr, useOn: true }; 11041 } 11042 11043 // Gives - load from Stripe via API and display as horizontal ticker 11044 async function loadRecentGives() { 11045 const ticker = document.getElementById('givesTicker'); 11046 11047 // Animated server cost number (static $800 with color animation) 11048 function getAnimatedCost() { 11049 return `<span class="server-cost-number" style="color: #ff6b9d; font-weight: bold;">$800</span>`; 11050 } 11051 11052 // Translated ticker messages 11053 const tickerMessages = { 11054 en: { 11055 serverCost: `💸 Server bills to keep AC running cost upwards of <strong>${getAnimatedCost()}/month</strong>.`, 11056 cta: 'Paying what you want for AC keeps services online and free to use and helps <a href="https://aesthetic.computer/@jeffrey" class="pink-handle">@jeffrey</a> develop new features.', 11057 got: 'Got' 11058 }, 11059 da: { 11060 serverCost: `💸 Serverregninger for at holde AC kørende koster op mod <strong>${getAnimatedCost()} kr/måned</strong>.`, 11061 cta: 'Betal hvad du vil for AC og hold tjenester online og gratis og hjælp <a href="https://aesthetic.computer/@jeffrey" class="pink-handle">@jeffrey</a> med at udvikle nye funktioner.', 11062 got: 'Modtog' 11063 }, 11064 de: { 11065 serverCost: `💸 Serverkosten für AC laufen auf über <strong>${getAnimatedCost()}$/Monat</strong> hinaus.`, 11066 cta: 'Zahle was du willst für AC, halte die Dienste online und kostenlos und hilf <a href="https://aesthetic.computer/@jeffrey" class="pink-handle">@jeffrey</a> neue Funktionen zu entwickeln.', 11067 got: 'Erhalten' 11068 }, 11069 es: { 11070 serverCost: `💸 Las facturas del servidor para mantener AC funcionando cuestan más de <strong>${getAnimatedCost()}/mes</strong>.`, 11071 cta: 'Paga lo que quieras por AC para mantener los servicios en línea y gratuitos, y ayudar a <a href="https://aesthetic.computer/@jeffrey" class="pink-handle">@jeffrey</a> a desarrollar nuevas funciones.', 11072 got: 'Recibido' 11073 }, 11074 zh: { 11075 serverCost: `💸 维持 AC 运行的服务器费用高达 <strong>每月${getAnimatedCost()}美元</strong>。`, 11076 cta: '为 AC 支付你想要的金额,保持服务在线且免费,并帮助 <a href="https://aesthetic.computer/@jeffrey" class="pink-handle">@jeffrey</a> 开发新功能。', 11077 got: '收到' 11078 } 11079 }; 11080 11081 const msgs = tickerMessages[currentLang] || tickerMessages.en; 11082 let serverCostItem = `<span class="gives-ticker-item gives-ticker-cta-item">${msgs.serverCost}</span>`; 11083 const ctaItem = `<span class="gives-ticker-item gives-ticker-cta-item">${msgs.cta}</span>`; 11084 11085 try { 11086 // Use local function endpoint in dev, production URL otherwise 11087 const isDev = window.location.hostname === 'localhost' || window.location.hostname.includes('local.'); 11088 const givesUrl = isDev 11089 ? 'https://localhost:8888/.netlify/functions/gives' 11090 : 'https://aesthetic.computer/api/gives'; 11091 const res = await fetch(givesUrl); 11092 const data = await res.json(); 11093 11094 // Update stats badges (subscribers shown in badge, not ticker) 11095 if (data.activeSubscribers > 0) { 11096 // No longer add to ticker - shown in monthly badge instead 11097 } 11098 11099 // Update stats badges on canvas 11100 const givesStatsEl = document.getElementById('giveStatsGives'); 11101 const subsStatsEl = document.getElementById('giveStatsSubs'); 11102 11103 if (givesStatsEl && data.monthlyCount > 0) { 11104 // Get current month name (short form) 11105 const monthNames = { 11106 en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], 11107 da: ['Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec'], 11108 de: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'], 11109 es: ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'], 11110 zh: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'], 11111 }; 11112 const month = new Date().getMonth(); 11113 const monthName = (monthNames[currentLang] || monthNames.en)[month]; 11114 11115 // Format: "Got $X so far from N gives this Jan." 11116 const gotLabels = { en: 'Got', da: 'Fik', de: 'Erhielt', es: 'Recibido', zh: '收到' }; 11117 const soFarLabels = { en: 'so far from', da: 'indtil nu fra', de: 'bisher von', es: 'hasta ahora de', zh: '至今来自' }; 11118 const givesLabels = { en: 'gives this', da: 'gaver i', de: 'Gaben im', es: 'donaciones en', zh: '捐赠于' }; 11119 11120 const valueEl = givesStatsEl.querySelector('.ticker-stat-value'); 11121 const labelEl = givesStatsEl.querySelector('.ticker-stat-label'); 11122 const amountEl = givesStatsEl.querySelector('.ticker-stat-amount'); 11123 11124 // Build the full text: "Got $X so far from N gives this Jan." 11125 const got = gotLabels[currentLang] || gotLabels.en; 11126 const soFar = soFarLabels[currentLang] || soFarLabels.en; 11127 const givesText = givesLabels[currentLang] || givesLabels.en; 11128 11129 // Hide the old value/label elements, use amount for full text 11130 if (valueEl) valueEl.style.display = 'none'; 11131 if (labelEl) labelEl.style.display = 'none'; 11132 if (amountEl && data.monthlyTotalUSD > 0) { 11133 amountEl.innerHTML = `<span class="got">${got}</span> <span class="amount-value">$${data.monthlyTotalUSD}</span> <span class="so-far">${soFar} <span class="count-value">${data.monthlyCount}</span> ${givesText} ${monthName}.</span>`; 11134 } 11135 givesStatsEl.classList.add('visible'); 11136 } 11137 11138 if (subsStatsEl && data.activeSubscribers > 0) { 11139 const subLabels = { en: 'monthly givers', da: 'månedlige givere', de: 'monatliche Geber', es: 'donantes mensuales', zh: '月度捐赠者' }; 11140 const valueEl = subsStatsEl.querySelector('.ticker-stat-value'); 11141 const labelEl = subsStatsEl.querySelector('.ticker-stat-label'); 11142 if (valueEl) valueEl.textContent = data.activeSubscribers; 11143 if (labelEl) labelEl.textContent = subLabels[currentLang] || subLabels.en; 11144 subsStatsEl.classList.add('visible'); 11145 } 11146 11147 if (!data.gives?.length) { 11148 // Show CTA messages scrolling in the ticker when no gives 11149 ticker.innerHTML = `<div class="gives-ticker-track">${serverCostItem}${ctaItem}${serverCostItem}${ctaItem}${serverCostItem}${ctaItem}</div>`; 11150 // Adjust speed for CTA-only ticker 11151 const track = ticker.querySelector('.gives-ticker-track'); 11152 if (track) { 11153 const contentWidth = track.scrollWidth / 3; 11154 const speed = Math.max(25, contentWidth / 30); 11155 track.style.animationDuration = `${speed}s`; 11156 } 11157 return; 11158 } 11159 11160 givesData = data.gives; 11161 11162 // Translated "on" preposition for dates 11163 const onPrep = { en: 'on', da: 'd.', de: 'am', es: 'el', zh: '' }; 11164 const onText = onPrep[currentLang] || onPrep.en; 11165 11166 // Build horizontal ticker items - "Got $5 today" or "Got $5 on Dec 15" format 11167 const giveItems = givesData.map(give => { 11168 const { time: timeStr, useOn } = formatGiveTime(give.createdAt); 11169 const hasNote = give.note && give.note.trim(); 11170 const noteHtml = hasNote ? `<span class="sep">:</span><span class="note">${give.note}</span>` : ''; 11171 const timeHtml = useOn ? `<span class="time">${onText ? onText + ' ' : ''}${timeStr}</span>` : `<span class="time">${timeStr}</span>`; 11172 11173 // Monthly donations show with /mo suffix and yellow styling 11174 if (give.isRecurring) { 11175 return `<span class="gives-ticker-item monthly"><span class="got">${msgs.got}</span><span class="amount">${give.amountDisplay}/mo</span>${timeHtml}${noteHtml}</span>`; 11176 } 11177 11178 return `<span class="gives-ticker-item"><span class="got">${msgs.got}</span><span class="amount">${give.amountDisplay}</span>${timeHtml}${noteHtml}</span>`; 11179 }); 11180 11181 // Intersperse give items with server messages for better readability 11182 // Pattern: serverCost, 2-3 gives, cta, 2-3 gives, repeat 11183 const interspersedItems = []; 11184 const chunkSize = Math.max(2, Math.ceil(giveItems.length / 4)); // Split gives into ~4 groups 11185 for (let i = 0; i < giveItems.length; i++) { 11186 if (i === 0) interspersedItems.push(serverCostItem); 11187 interspersedItems.push(giveItems[i]); 11188 // After every chunk, add a server message 11189 if ((i + 1) % chunkSize === 0 && i < giveItems.length - 1) { 11190 interspersedItems.push(i % (chunkSize * 2) === chunkSize - 1 ? ctaItem : serverCostItem); 11191 } 11192 } 11193 // End with CTA if we didn't just add one 11194 if (giveItems.length > 0) interspersedItems.push(ctaItem); 11195 11196 const items = interspersedItems.join(''); 11197 11198 // Duplicate content for seamless scroll (tripled) 11199 ticker.innerHTML = `<div class="gives-ticker-track">${items}${items}${items}</div>`; 11200 11201 // Adjust animation speed based on content length (content is tripled) 11202 const track = ticker.querySelector('.gives-ticker-track'); 11203 if (track) { 11204 const contentWidth = track.scrollWidth / 3; // one-third since tripled 11205 const speed = Math.max(20, contentWidth / 35); // ~35px per second 11206 track.style.animationDuration = `${speed}s`; 11207 } 11208 11209 // Float all gives on the jeffreys slideshow (with or without notes) 11210 if (givesData.length > 0) { 11211 startFloatingGives(givesData); 11212 } 11213 } catch (e) { 11214 console.error('Gives load error:', e); 11215 if (SERVER_SUSPENDED) { 11216 renderSuspended(ticker, 'inline'); 11217 } else { 11218 const errorTexts = { en: 'Unable to load gives', da: 'Kunne ikke indlæse gaver', de: 'Konnte Gaben nicht laden', es: 'No se pudieron cargar las donaciones', zh: '无法加载捐赠' }; 11219 ticker.innerHTML = `<div class="gives-ticker-empty">${errorTexts[currentLang] || errorTexts.en}</div>`; 11220 } 11221 } 11222 } 11223 11224 // Floating gives on jeffreys canvas 11225 function startFloatingGives(allGives) { 11226 // Only allow one instance of floating gives 11227 if (floatingGivesStarted) return; 11228 floatingGivesStarted = true; 11229 11230 const slideshow = document.getElementById('jeffreysSlideshow'); 11231 if (!slideshow) { 11232 floatingGivesStarted = false; 11233 return; 11234 } 11235 11236 let giveIndex = 0; 11237 const visibleGives = new Set(); // Track gives currently on screen by unique key 11238 let isShowingGive = false; // Prevent concurrent calls 11239 11240 // Format time ago (localized) 11241 function timeAgo(dateStr) { 11242 const date = new Date(dateStr); 11243 const now = new Date(); 11244 const diffMs = now - date; 11245 const diffMins = Math.floor(diffMs / 60000); 11246 const diffHours = Math.floor(diffMs / 3600000); 11247 const diffDays = Math.floor(diffMs / 86400000); 11248 11249 // Translated time ago strings 11250 const timeAgoTexts = { 11251 en: { now: 'now', mAgo: 'm ago', hAgo: 'h ago', dAgo: 'd ago' }, 11252 da: { now: 'nu', mAgo: 'm siden', hAgo: 't siden', dAgo: 'd siden' }, 11253 de: { now: 'jetzt', mAgo: 'm her', hAgo: 'h her', dAgo: 'T her' }, 11254 es: { now: 'ahora', mAgo: 'm', hAgo: 'h', dAgo: 'd' }, 11255 zh: { now: '现在', mAgo: '分钟前', hAgo: '小时前', dAgo: '天前' } 11256 }; 11257 const ta = timeAgoTexts[currentLang] || timeAgoTexts.en; 11258 const locales = { en: 'en-US', da: 'da-DK', de: 'de-DE', es: 'es-ES', zh: 'zh-CN' }; 11259 11260 if (diffMins < 1) return ta.now; 11261 if (diffMins < 60) return `${diffMins}${ta.mAgo}`; 11262 if (diffHours < 24) return `${diffHours}${ta.hAgo}`; 11263 if (diffDays < 7) return `${diffDays}${ta.dAgo}`; 11264 return date.toLocaleDateString(locales[currentLang] || 'en-US', { month: 'short', day: 'numeric' }); 11265 } 11266 11267 // Convert text to animated characters - alphabet soup on strings with depth layering 11268 function animateText(text) { 11269 return [...text].map((char, i) => { 11270 if (char === ' ') return ' '; 11271 // Random soup properties - each letter dangling on its own string 11272 const delay = i * 0.06 + Math.random() * 0.2; 11273 const duration = 2.0 + Math.random() * 2.0; // 2-4s 11274 const swingX = (Math.random() - 0.5) * 10; // -5 to 5px 11275 const swingY = -6 - Math.random() * 10; // -6 to -16px 11276 const rotBase = (Math.random() - 0.5) * 8; // -4 to 4deg base tilt 11277 const rotSwing = 3 + Math.random() * 6; // 3-9deg swing 11278 // Random z-index for depth layering relative to slideshow layers: 11279 // 1 = behind color tint overlay (behind logo/photos visible through) 11280 // 3 = between overlays (middle depth) 11281 // 10 = in front of everything (clearly above logo) 11282 const zIndex = Math.random() < 0.35 ? 1 : (Math.random() < 0.5 ? 3 : 10); 11283 return `<span class="char" style="--char-delay: ${delay.toFixed(2)}s; --char-duration: ${duration.toFixed(1)}s; --swing-x: ${swingX.toFixed(1)}px; --swing-y: ${swingY.toFixed(1)}px; --rot-base: ${rotBase.toFixed(1)}deg; --rot-swing: ${rotSwing.toFixed(1)}deg; --char-z: ${zIndex}">${char}</span>`; 11284 }).join(''); 11285 } 11286 11287 function showNextGive() { 11288 // Prevent concurrent calls 11289 if (isShowingGive) return; 11290 isShowingGive = true; 11291 11292 // Find a give that isn't currently visible (use unique key: amount + timestamp) 11293 let attempts = 0; 11294 const startIndex = giveIndex; 11295 let give = allGives[giveIndex]; 11296 let giveKey = `${give.amountDisplay}-${give.createdAt || give.timestamp}`; 11297 11298 while (visibleGives.has(giveKey) && attempts < allGives.length) { 11299 giveIndex = (giveIndex + 1) % allGives.length; 11300 give = allGives[giveIndex]; 11301 giveKey = `${give.amountDisplay}-${give.createdAt || give.timestamp}`; 11302 attempts++; 11303 } 11304 11305 // If all gives are visible, skip this cycle 11306 if (visibleGives.has(giveKey)) { 11307 giveIndex = (startIndex + 1) % allGives.length; 11308 isShowingGive = false; 11309 return; 11310 } 11311 11312 // Mark as visible IMMEDIATELY before any async stuff 11313 visibleGives.add(giveKey); 11314 11315 // Move to next for subsequent calls 11316 giveIndex = (giveIndex + 1) % allGives.length; 11317 11318 // Build text like ticker: "Got $5 today" or "Got $5: note" 11319 const suffix = give.isRecurring ? '/mo' : ''; 11320 const { time: timeStr, useOn } = formatGiveTime(give.createdAt || give.timestamp); 11321 const amountText = give.amountDisplay + suffix; 11322 const hasNote = give.note && give.note.trim(); 11323 // Translated "on" preposition for dates 11324 const onPrep = { en: 'on', da: 'd.', de: 'am', es: 'el', zh: '' }; 11325 const onText = onPrep[currentLang] || onPrep.en; 11326 const timeText = useOn ? (onText ? `${onText} ${timeStr}` : timeStr) : timeStr; 11327 11328 // Position: mostly left side (80%), occasionally center (20%) 11329 const posRand = Math.random(); 11330 const isLeftSide = posRand < 0.8; 11331 11332 // Create HTML floating give element (GPU-accelerated CSS animations) 11333 const floatEl = document.createElement('div'); 11334 floatEl.className = `floating-give ${isLeftSide ? 'left-side' : 'center-side'}${give.isRecurring ? ' monthly' : ''}`; 11335 11336 // Start from below bottom edge of container (100-110%) so they float up into view 11337 const topPercent = 100 + Math.random() * 10; 11338 floatEl.style.top = `${topPercent}%`; 11339 11340 // Random drift values for ambient floating 11341 const driftStart = (Math.random() - 0.5) * 60; 11342 const driftEnd = (Math.random() - 0.5) * 80; 11343 floatEl.style.setProperty('--drift-start', `${driftStart}px`); 11344 floatEl.style.setProperty('--drift-end', `${driftEnd}px`); 11345 11346 // Helper to wrap text in character spans for soup animation 11347 // Use spread operator [...text] to properly handle emojis (multi-codepoint) 11348 const wrapChars = (text, extraClass = '') => { 11349 return [...text].map((ch, i) => { 11350 const duration = 2 + Math.random() * 2; 11351 const delay = Math.random() * 2; 11352 const zIndex = Math.floor(Math.random() * 10); 11353 return `<span class="char ${extraClass}" style="--char-duration:${duration}s;--char-delay:${delay}s;--char-z:${zIndex}">${ch === ' ' ? '&nbsp;' : ch}</span>`; 11354 }).join(''); 11355 }; 11356 11357 // Build HTML like ticker: "Got $5 today" or "Got $5: note" 11358 // Get localized "Got" text based on current language 11359 const gotTexts = { en: 'Got', da: 'Modtog', de: 'Erhalten', es: 'Recibido', zh: '收到' }; 11360 const gotText = gotTexts[currentLang] || gotTexts.en; 11361 const gotSpan = `<span class="got">${wrapChars(gotText)}</span>`; 11362 const amountSpan = `<span class="amount">${wrapChars(amountText)}</span>`; 11363 const timeSpan = `<span class="time">${wrapChars(timeText)}</span>`; 11364 const noteSpan = hasNote ? `<span class="note">: ${wrapChars(give.note.trim())}</span>` : ''; 11365 11366 floatEl.innerHTML = gotSpan + amountSpan + timeSpan + noteSpan; 11367 11368 // Add to slideshow container 11369 const container = document.querySelector('.jeffreys-slideshow'); 11370 if (container) { 11371 container.appendChild(floatEl); 11372 } 11373 11374 isShowingGive = false; 11375 11376 // Remove element after animation completes 11377 setTimeout(() => { 11378 visibleGives.delete(giveKey); 11379 floatEl.remove(); 11380 }, 30000); 11381 } 11382 11383 // Floating animations disabled 11384 // setTimeout(showNextGive, 3000); 11385 // setInterval(() => { 11386 // if (document.visibilityState === 'visible') { 11387 // showNextGive(); 11388 // } 11389 // }, 12000 + Math.random() * 6000); 11390 } 11391 11392 setTimeout(loadRecentGives, 300); 11393 11394 // Pause ticker when not visible 11395 const tickerObserver = new IntersectionObserver((entries) => { 11396 entries.forEach(entry => { 11397 const ticker = entry.target; 11398 if (entry.isIntersecting) { 11399 ticker.classList.remove('paused'); 11400 } else { 11401 ticker.classList.add('paused'); 11402 } 11403 }); 11404 }, { threshold: 0.1 }); 11405 const tickerEl = document.getElementById('givesTicker'); 11406 if (tickerEl) tickerObserver.observe(tickerEl); 11407 11408 // Drag to scroll ticker 11409 if (tickerEl) { 11410 let isDragging = false; 11411 let startX = 0; 11412 let currentOffset = 0; 11413 let trackOffset = 0; 11414 let resumeTimeout = null; 11415 11416 function getTrack() { 11417 return tickerEl.querySelector('.gives-ticker-track'); 11418 } 11419 11420 function getTrackOffset() { 11421 const track = getTrack(); 11422 if (!track) return 0; 11423 const transform = getComputedStyle(track).transform; 11424 if (transform === 'none') return 0; 11425 const matrix = new DOMMatrixReadOnly(transform); 11426 return matrix.m41; 11427 } 11428 11429 function startDrag(clientX) { 11430 const track = getTrack(); 11431 if (!track) return; 11432 if (resumeTimeout) clearTimeout(resumeTimeout); 11433 isDragging = true; 11434 startX = clientX; 11435 trackOffset = getTrackOffset(); 11436 tickerEl.classList.add('dragging'); 11437 // Apply current position as inline style immediately 11438 track.style.transform = `translateX(${trackOffset}px)`; 11439 } 11440 11441 tickerEl.addEventListener('mousedown', (e) => { 11442 startDrag(e.clientX); 11443 e.preventDefault(); 11444 }); 11445 11446 tickerEl.addEventListener('touchstart', (e) => { 11447 startDrag(e.touches[0].clientX); 11448 }, { passive: true }); 11449 11450 window.addEventListener('mousemove', (e) => { 11451 if (!isDragging) return; 11452 const track = getTrack(); 11453 if (!track) return; 11454 const deltaX = e.clientX - startX; 11455 currentOffset = trackOffset + deltaX; 11456 track.style.transform = `translateX(${currentOffset}px)`; 11457 }); 11458 11459 window.addEventListener('touchmove', (e) => { 11460 if (!isDragging) return; 11461 const track = getTrack(); 11462 if (!track) return; 11463 const deltaX = e.touches[0].clientX - startX; 11464 currentOffset = trackOffset + deltaX; 11465 track.style.transform = `translateX(${currentOffset}px)`; 11466 }, { passive: true }); 11467 11468 function stopDragging() { 11469 if (!isDragging) return; 11470 const track = getTrack(); 11471 isDragging = false; 11472 // Keep manual position for 2s before resuming animation 11473 resumeTimeout = setTimeout(() => { 11474 tickerEl.classList.remove('dragging'); 11475 if (track) track.style.transform = ''; 11476 }, 2000); 11477 } 11478 11479 window.addEventListener('mouseup', stopDragging); 11480 window.addEventListener('touchend', stopDragging); 11481 } 11482 11483 // Animate server cost colors (cycling hue animation) 11484 let serverCostPhase = Math.random() * Math.PI * 2; 11485 let serverCostPaused = false; 11486 function animateServerCost() { 11487 if (!serverCostPaused) { 11488 serverCostPhase += 0.02; // Smooth oscillation 11489 11490 document.querySelectorAll('.server-cost-number').forEach(el => { 11491 // Cycle through colors (pink → cyan → gold → green → pink) 11492 const hue = (serverCostPhase * 30) % 360; 11493 el.style.color = `hsl(${hue}, 70%, 65%)`; 11494 }); 11495 } 11496 requestAnimationFrame(animateServerCost); 11497 } 11498 setTimeout(animateServerCost, 500); // Start after ticker loads 11499 11500 // Pause server cost animation when ticker not visible 11501 const costObserver = new IntersectionObserver((entries) => { 11502 entries.forEach(entry => { 11503 serverCostPaused = !entry.isIntersecting; 11504 }); 11505 }, { threshold: 0.1 }); 11506 if (tickerEl) costObserver.observe(tickerEl); 11507 11508 // Determine API base URL (relative for local dev, absolute for production) 11509 const apiBase = window.location.hostname === 'localhost' || window.location.hostname.includes('local.') 11510 ? '' // Use relative URLs in dev 11511 : 'https://aesthetic.computer'; 11512 11513 // Oven URL for webp generation (always use production oven) 11514 const OVEN_URL = 'https://oven.aesthetic.computer'; 11515 11516 // KidLisp count from dedicated endpoint 11517 async function loadKidlispCount() { 11518 try { 11519 const res = await fetch(`${apiBase}/api/kidlisp-count`); 11520 const data = await res.json(); 11521 const count = data.count || 0; 11522 11523 const el = document.querySelector('#stat-kidlisp .stat-value'); 11524 if (el && count) el.textContent = count.toLocaleString(); 11525 11526 // Update stat values for sorting and re-sort 11527 if (window._statValues) { 11528 window._statValues['stat-kidlisp'] = count; 11529 sortStatBoxes(); 11530 } 11531 } catch (e) { 11532 console.warn('Could not load kidlisp count:', e); 11533 } 11534 } 11535 setTimeout(loadKidlispCount, 400); 11536 11537 // TV video feed - load and cycle through tape videos 11538 let tvTapes = []; 11539 let tvIndex = 0; 11540 let tvPlayer = null; 11541 let tvMuted = true; // Preserve mute state across tape changes 11542 let tvErrorCount = 0; 11543 let tvPlayId = 0; // Track current play session to prevent stale callbacks 11544 const TV_MAX_ERRORS = 5; // Stop trying after too many consecutive errors 11545 11546 // Scrubbing state - defined once outside playTVVideo to avoid duplicate listeners 11547 let tvIsScrubbing = false; 11548 let tvScrubFn = null; 11549 document.addEventListener('mousemove', (e) => { if (tvIsScrubbing && tvScrubFn) tvScrubFn(e); }); 11550 document.addEventListener('mouseup', () => { tvIsScrubbing = false; }); 11551 11552 async function loadTVFeed() { 11553 const container = document.getElementById('tvPlayer'); 11554 try { 11555 const res = await fetch(`${apiBase}/api/tv?types=tape&limit=20&filter=recent`); 11556 const data = await res.json(); 11557 11558 // Get tapes from mixed array or media.tapes 11559 const tapes = data.mixed || data.media?.tapes || []; 11560 // Filter for tapes with completed MP4s (most reliable), then any with videoUrl 11561 tvTapes = tapes.filter(item => 11562 item.media?.videoUrl && item.media?.mp4Status === "complete" 11563 ); 11564 11565 // Fallback to any tape with videoUrl if no complete MP4s 11566 if (!tvTapes.length) { 11567 tvTapes = tapes.filter(item => item.media?.videoUrl); 11568 } 11569 11570 if (!tvTapes.length) { 11571 if (SERVER_SUSPENDED) { 11572 renderSuspended(container, 'card'); 11573 } else { 11574 container.innerHTML = '<div class="tv-no-signal">No signal</div>'; 11575 } 11576 return; 11577 } 11578 11579 playTVVideo(0); 11580 } catch (e) { 11581 console.error('TV load error:', e); 11582 if (SERVER_SUSPENDED) { 11583 renderSuspended(container, 'card'); 11584 } else { 11585 container.innerHTML = '<div class="tv-no-signal">No signal</div>'; 11586 } 11587 } 11588 } 11589 11590 function playTVVideo(index) { 11591 const container = document.getElementById('tvPlayer'); 11592 const header = document.getElementById('tvHeader'); 11593 const tape = tvTapes[index]; 11594 11595 // Increment play ID to invalidate callbacks from previous video loads 11596 const currentPlayId = ++tvPlayId; 11597 11598 if (!tape) { 11599 container.innerHTML = '<div class="tv-no-signal">No signal</div>'; 11600 return; 11601 } 11602 11603 // Update header with current tape code, timestamp, and upcoming codes 11604 const tapeCode = tape.code ? `!${tape.code}` : '📼 tapes'; 11605 11606 // Format timestamp 11607 let timestampStr = ''; 11608 if (tape.createdAt || tape.timestamp) { 11609 const date = new Date(tape.createdAt || tape.timestamp); 11610 const options = { month: 'short', day: 'numeric', year: 'numeric' }; 11611 timestampStr = `taped on ${date.toLocaleDateString('en-US', options)}`; 11612 } 11613 11614 // Get next 2-3 upcoming codes with their indices 11615 const upcomingCodes = []; 11616 for (let i = 1; i <= 3; i++) { 11617 const nextIndex = (index + i) % tvTapes.length; 11618 const nextTape = tvTapes[nextIndex]; 11619 if (nextTape && nextTape.code && upcomingCodes.length < 3) { 11620 upcomingCodes.push({ code: `!${nextTape.code}`, index: nextIndex }); 11621 } 11622 } 11623 const upcomingHtml = upcomingCodes.length ? 11624 `<span class="tv-upcoming">${upcomingCodes.map(c => `<span class="tv-upcoming-code" data-index="${c.index}">${c.code}</span>`).join('')}</span>` : ''; 11625 11626 if (header) { 11627 header.innerHTML = ` 11628 <div class="tv-header-row"> 11629 <span class="tv-code">${tapeCode}</span> 11630 ${upcomingHtml} 11631 </div> 11632 ${timestampStr ? `<span class="tv-timestamp">${timestampStr}</span>` : ''} 11633 `; 11634 11635 // Make upcoming codes clickable to skip to that tape 11636 header.querySelectorAll('.tv-upcoming-code').forEach(el => { 11637 el.addEventListener('click', (e) => { 11638 e.preventDefault(); 11639 e.stopPropagation(); 11640 const targetIndex = parseInt(el.dataset.index, 10); 11641 if (!isNaN(targetIndex)) { 11642 tvIndex = targetIndex; 11643 playTVVideo(tvIndex); 11644 } 11645 }); 11646 }); 11647 11648 // Make current tape code clickable to advance to next tape 11649 const currentCode = header.querySelector('.tv-code'); 11650 if (currentCode) { 11651 currentCode.style.cursor = 'pointer'; 11652 currentCode.addEventListener('click', (e) => { 11653 e.preventDefault(); 11654 e.stopPropagation(); 11655 tvIndex = (tvIndex + 1) % tvTapes.length; 11656 playTVVideo(tvIndex); 11657 }); 11658 } 11659 } 11660 11661 // Remove ALL old slides and glows (not just one) to prevent overlap on rapid clicks 11662 // Also STOP old videos immediately to prevent continued loading 11663 const oldSlides = container.querySelectorAll('.tv-slide'); 11664 oldSlides.forEach(oldSlide => { 11665 const oldVideo = oldSlide.querySelector('video'); 11666 if (oldVideo) { 11667 oldVideo.pause(); 11668 oldVideo.removeAttribute('src'); 11669 oldVideo.load(); // Abort any ongoing fetch 11670 } 11671 oldSlide.classList.add('exiting'); 11672 setTimeout(() => oldSlide.remove(), 400); 11673 }); 11674 container.querySelectorAll('.tv-bg-glow').forEach(oldGlow => { 11675 oldGlow.pause(); 11676 oldGlow.removeAttribute('src'); 11677 oldGlow.style.opacity = '0'; 11678 setTimeout(() => oldGlow.remove(), 400); 11679 }); 11680 11681 // Create background glow video (in container, not slide, to avoid clipping) 11682 const bgGlow = document.createElement('video'); 11683 bgGlow.className = 'tv-bg-glow'; 11684 bgGlow.src = tape.media.videoUrl; 11685 bgGlow.muted = true; 11686 bgGlow.playsInline = true; 11687 bgGlow.style.transition = 'opacity 0.4s ease'; 11688 container.insertBefore(bgGlow, container.firstChild); 11689 11690 // Create backdrop glow video (behind the entire panel) 11691 const backdropEl = document.getElementById('tvBackdropGlow'); 11692 if (backdropEl) { 11693 backdropEl.innerHTML = ''; 11694 const backdropVideo = document.createElement('video'); 11695 backdropVideo.src = tape.media.videoUrl; 11696 backdropVideo.muted = true; 11697 backdropVideo.playsInline = true; 11698 backdropVideo.autoplay = true; 11699 backdropVideo.loop = true; 11700 backdropEl.appendChild(backdropVideo); 11701 } 11702 11703 // Create new slide (main video only) 11704 const slide = document.createElement('div'); 11705 slide.className = 'tv-slide entering'; 11706 slide.innerHTML = ` 11707 <video 11708 src="${tape.media.videoUrl}" 11709 muted 11710 playsinline 11711 autoplay 11712 disablepictureinpicture 11713 disableremoteplayback 11714 controlslist="nodownload noremoteplayback noplaybackrate" 11715 ></video> 11716 `; 11717 container.appendChild(slide); 11718 11719 // Ensure progress bar and timer exist (only once) 11720 if (!container.querySelector('.tv-progress')) { 11721 const progressEl = document.createElement('div'); 11722 progressEl.className = 'tv-progress'; 11723 progressEl.innerHTML = '<div class="tv-progress-bar"></div>'; 11724 container.appendChild(progressEl); 11725 } 11726 if (!container.querySelector('.tv-timer')) { 11727 const timerEl = document.createElement('span'); 11728 timerEl.className = 'tv-timer'; 11729 timerEl.textContent = '0:00 / 0:00'; 11730 container.appendChild(timerEl); 11731 } 11732 11733 // Add mute button (only once) 11734 if (!container.querySelector('.tv-mute-btn')) { 11735 const muteBtn = document.createElement('button'); 11736 muteBtn.className = 'tv-mute-btn muted'; 11737 muteBtn.innerHTML = ` 11738 <svg viewBox="0 0 24 24"> 11739 <path d="M3 9v6h4l5 5V4L7 9H3z"/> 11740 <path class="sound-wave" d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/> 11741 <path class="sound-wave" d="M19 12c0 2.58-1.17 4.87-3 6.4v2.15c2.78-1.82 4.63-4.96 4.63-8.55 0-3.59-1.85-6.73-4.63-8.55v2.15c1.83 1.53 3 3.82 3 6.4z"/> 11742 </svg> 11743 `; 11744 muteBtn.onclick = (e) => { 11745 e.preventDefault(); 11746 e.stopPropagation(); 11747 if (tvPlayer) { 11748 tvPlayer.muted = !tvPlayer.muted; 11749 tvMuted = tvPlayer.muted; // Store for next tape 11750 muteBtn.classList.toggle('muted', tvPlayer.muted); 11751 } 11752 }; 11753 container.appendChild(muteBtn); 11754 } 11755 11756 tvPlayer = slide.querySelector('video'); 11757 tvPlayer.volume = 0.3; // Set default volume to 30% 11758 tvPlayer.muted = tvMuted; // Preserve mute state from previous tape 11759 11760 // Update mute button visual state 11761 const muteBtn = container.querySelector('.tv-mute-btn'); 11762 if (muteBtn) muteBtn.classList.toggle('muted', tvMuted); 11763 11764 // Sync background glow with main video (glow is in container, not slide) 11765 if (bgGlow) { 11766 tvPlayer.addEventListener('play', () => bgGlow.play()); 11767 tvPlayer.addEventListener('pause', () => bgGlow.pause()); 11768 tvPlayer.addEventListener('seeked', () => { bgGlow.currentTime = tvPlayer.currentTime; }); 11769 // Start bg glow when main video starts 11770 tvPlayer.addEventListener('canplay', () => { 11771 bgGlow.currentTime = tvPlayer.currentTime; 11772 if (!tvPlayer.paused) bgGlow.play(); 11773 }, { once: true }); 11774 } 11775 11776 const progressBar = container.querySelector('.tv-progress-bar'); 11777 const progressContainer = container.querySelector('.tv-progress'); 11778 const timerEl = container.querySelector('.tv-timer'); 11779 11780 // Format seconds as m:ss 11781 function formatTime(sec) { 11782 const m = Math.floor(sec / 60); 11783 const s = Math.floor(sec % 60); 11784 return `${m}:${s.toString().padStart(2, '0')}`; 11785 } 11786 11787 // Scrubbing functionality - uses global document listeners defined once above 11788 function scrubTo(e) { 11789 if (!tvPlayer.duration) return; 11790 const rect = progressContainer.getBoundingClientRect(); 11791 const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); 11792 tvPlayer.currentTime = pct * tvPlayer.duration; 11793 } 11794 tvScrubFn = scrubTo; // Update global scrub function reference 11795 11796 progressContainer.addEventListener('mousedown', (e) => { 11797 tvIsScrubbing = true; 11798 scrubTo(e); 11799 }); 11800 progressContainer.addEventListener('click', (e) => { 11801 e.stopPropagation(); // Prevent play/pause toggle 11802 scrubTo(e); 11803 }); 11804 11805 // Toggle play/pause with big button feedback 11806 let bigPlayTimeout = null; 11807 function togglePlayback(e) { 11808 e.preventDefault(); 11809 e.stopPropagation(); 11810 const indicator = container.querySelector('.tv-play-indicator'); 11811 const bigPlay = container.querySelector('.tv-big-play'); 11812 11813 if (tvPlayer.paused) { 11814 tvPlayer.play(); 11815 if (indicator) indicator.classList.remove('paused'); 11816 if (bigPlay) { 11817 bigPlay.classList.remove('paused'); 11818 bigPlay.classList.add('visible'); 11819 } 11820 } else { 11821 tvPlayer.pause(); 11822 if (indicator) indicator.classList.add('paused'); 11823 if (bigPlay) { 11824 bigPlay.classList.add('paused'); 11825 bigPlay.classList.add('visible'); 11826 } 11827 } 11828 11829 // Hide big play button after delay 11830 if (bigPlayTimeout) clearTimeout(bigPlayTimeout); 11831 bigPlayTimeout = setTimeout(() => { 11832 if (bigPlay) bigPlay.classList.remove('visible'); 11833 }, 600); 11834 } 11835 11836 // Add play/pause indicator (small) 11837 if (!container.querySelector('.tv-play-indicator')) { 11838 const indicator = document.createElement('div'); 11839 indicator.className = 'tv-play-indicator'; 11840 container.appendChild(indicator); 11841 } 11842 11843 // Add big centered play/pause button 11844 if (!container.querySelector('.tv-big-play')) { 11845 const bigPlay = document.createElement('div'); 11846 bigPlay.className = 'tv-big-play'; 11847 container.appendChild(bigPlay); 11848 } 11849 11850 // Make video and timer clickable to pause/play (not header - it has nav links) 11851 slide.addEventListener('click', togglePlayback); 11852 timerEl.addEventListener('click', togglePlayback); 11853 11854 // Attach all event listeners once 11855 function attachListeners() { 11856 tvPlayer.addEventListener('timeupdate', () => { 11857 if (tvPlayer.duration) { 11858 const pct = (tvPlayer.currentTime / tvPlayer.duration) * 100; 11859 progressBar.style.width = pct + '%'; 11860 // Update timer: current / total 11861 const current = formatTime(tvPlayer.currentTime); 11862 const total = formatTime(tvPlayer.duration); 11863 if (timerEl) timerEl.textContent = `${current} / ${total}`; 11864 } 11865 }); 11866 11867 tvPlayer.addEventListener('ended', () => { 11868 // Ignore if this video is from an old play session (user clicked rapidly) 11869 if (currentPlayId !== tvPlayId) { 11870 return; 11871 } 11872 tvErrorCount = 0; // Reset error count on successful play 11873 tvIndex = (tvIndex + 1) % tvTapes.length; 11874 playTVVideo(tvIndex); 11875 }); 11876 tvPlayer.addEventListener('canplay', () => { 11877 if (currentPlayId !== tvPlayId) { 11878 return; 11879 } 11880 tvErrorCount = 0; // Reset error count when video loads successfully 11881 }); 11882 tvPlayer.addEventListener('error', (e) => { 11883 // Ignore errors from old video loads (user clicked rapidly) 11884 if (currentPlayId !== tvPlayId) { 11885 return; 11886 } 11887 tvErrorCount++; 11888 if (tvErrorCount >= TV_MAX_ERRORS) { 11889 container.innerHTML = '<div class="tv-no-signal">No signal</div>'; 11890 return; 11891 } 11892 // Skip broken videos 11893 tvIndex = (tvIndex + 1) % tvTapes.length; 11894 setTimeout(() => playTVVideo(tvIndex), 500); 11895 }); 11896 } 11897 11898 attachListeners(); 11899 11900 tvPlayer.play().then(() => { 11901 }).catch((err) => { 11902 // Autoplay blocked - add click overlay (keep video and listeners intact) 11903 const overlay = document.createElement('div'); 11904 overlay.className = 'module-loading'; 11905 overlay.style.cursor = 'pointer'; 11906 overlay.innerHTML = '▶️'; 11907 overlay.onclick = () => { 11908 tvPlayer.play(); 11909 overlay.remove(); 11910 }; 11911 container.insertBefore(overlay, container.firstChild); 11912 }); 11913 } 11914 setTimeout(loadTVFeed, 500); 11915 11916 // AT Proto - cycle through actual records fetched from PDS 11917 let atRecords = []; 11918 let atRecordIndex = 0; 11919 let atCurrentSlot = 0; 11920 let atProgressInterval = null; 11921 const PDS_URL = 'https://at.aesthetic.computer'; 11922 const AT_CYCLE_DURATION = 5000; // 5 seconds per record 11923 11924 function startATProgressBar() { 11925 const progressBar = document.getElementById('atProgressBar'); 11926 if (!progressBar) return; 11927 11928 // Reset and animate 11929 progressBar.style.transition = 'none'; 11930 progressBar.style.width = '0%'; 11931 11932 requestAnimationFrame(() => { 11933 requestAnimationFrame(() => { 11934 progressBar.style.transition = `width ${AT_CYCLE_DURATION}ms linear`; 11935 progressBar.style.width = '100%'; 11936 }); 11937 }); 11938 } 11939 11940 async function loadATProtoRecords() { 11941 try { 11942 // Fetch user stats to get handles with ATProto accounts 11943 const statsRes = await fetch(`${apiBase}/.netlify/functions/atproto-user-stats?limit=20`); 11944 const statsData = await statsRes.json(); 11945 const users = (statsData.users || []).filter(u => u.handle && u.totalRecords > 0); 11946 11947 // Fetch actual records from PDS for top users 11948 const recordPromises = users.slice(0, 8).map(async (user) => { 11949 const handle = user.handle; // e.g. "jeffrey.at.aesthetic.computer" 11950 const shortHandle = handle.replace('.at.aesthetic.computer', ''); 11951 11952 try { 11953 // Fetch moods and paintings from this user's repo 11954 const [moodsRes, paintingsRes] = await Promise.all([ 11955 fetch(`${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=computer.aesthetic.mood&limit=3`), 11956 fetch(`${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=computer.aesthetic.painting&limit=2`) 11957 ]); 11958 11959 const moodsData = await moodsRes.json(); 11960 const paintingsData = await paintingsRes.json(); 11961 11962 const records = []; 11963 11964 // Add moods 11965 (moodsData.records || []).forEach(r => { 11966 records.push({ 11967 type: 'mood', 11968 uri: r.uri, 11969 handle: shortHandle, 11970 collection: 'mood', 11971 content: r.value?.mood || '', 11972 when: r.value?.when 11973 }); 11974 }); 11975 11976 // Add paintings 11977 (paintingsData.records || []).forEach(r => { 11978 const did = r.uri.split('/')[2]; 11979 const thumbCid = r.value?.thumbnail?.ref?.$link || r.value?.thumbnail?.ref; 11980 records.push({ 11981 type: 'painting', 11982 uri: r.uri, 11983 handle: shortHandle, 11984 collection: 'painting', 11985 thumbnail: thumbCid ? `${PDS_URL}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${thumbCid}` : null, 11986 when: r.value?.when 11987 }); 11988 }); 11989 11990 return records; 11991 } catch (e) { 11992 return []; 11993 } 11994 }); 11995 11996 const allRecordArrays = await Promise.all(recordPromises); 11997 atRecords = allRecordArrays.flat().sort(() => Math.random() - 0.5); // Shuffle 11998 11999 if (atRecords.length > 0) { 12000 showATRecord(0, 0); 12001 startATProgressBar(); 12002 12003 // Start cycling 12004 setInterval(() => { 12005 atRecordIndex = (atRecordIndex + 1) % atRecords.length; 12006 const nextSlot = atCurrentSlot === 0 ? 1 : 0; 12007 showATRecord(atRecordIndex, nextSlot); 12008 const currentEl = document.getElementById(`atRecord${atCurrentSlot}`); 12009 const nextEl = document.getElementById(`atRecord${nextSlot}`); 12010 if (currentEl) currentEl.classList.remove('active'); 12011 if (nextEl) nextEl.classList.add('active'); 12012 atCurrentSlot = nextSlot; 12013 startATProgressBar(); 12014 }, AT_CYCLE_DURATION); 12015 } else { 12016 // AT record elements no longer exist in the UI 12017 const atHandle0 = document.getElementById('atHandle0'); 12018 const atCenter0 = document.getElementById('atCenter0'); 12019 const atCollection0 = document.getElementById('atCollection0'); 12020 if (atHandle0) atHandle0.textContent = '@aesthetic'; 12021 if (atCenter0) atCenter0.innerHTML = '<div class="at-record-mood">🌐</div>'; 12022 if (atCollection0) atCollection0.textContent = 'status'; 12023 } 12024 } catch (e) { 12025 console.error('AT Proto records error:', e); 12026 // AT record elements no longer exist in the UI 12027 const atHandle0 = document.getElementById('atHandle0'); 12028 const atCenter0 = document.getElementById('atCenter0'); 12029 const atCollection0 = document.getElementById('atCollection0'); 12030 if (atHandle0) atHandle0.textContent = '@aesthetic'; 12031 if (atCenter0) atCenter0.innerHTML = '<div class="at-record-mood">🌐</div>'; 12032 if (atCollection0) atCollection0.textContent = 'status'; 12033 } 12034 } 12035 12036 function showATRecord(index, slot) { 12037 const record = atRecords[index]; 12038 if (!record) return; 12039 12040 const uriEl = document.getElementById(`atUri${slot}`); 12041 const centerEl = document.getElementById(`atCenter${slot}`); 12042 const handleEl = document.getElementById(`atHandle${slot}`); 12043 const typeEl = document.getElementById(`atType${slot}`); 12044 const collectionEl = document.getElementById(`atCollection${slot}`); 12045 12046 // If elements don't exist, bail out 12047 if (!uriEl || !centerEl || !handleEl || !typeEl || !collectionEl) return; 12048 12049 // Extract interesting parts from URI for proof 12050 const uriParts = record.uri.replace('at://', '').split('/'); 12051 const did = uriParts[0]; 12052 const collection = uriParts[1]; 12053 const rkey = uriParts[2]; 12054 12055 // Show more of the hash/rkey for proof 12056 const shortDid = did.substring(0, 16) + '...'; 12057 const displayUri = `${shortDid}/${collection}/${rkey}`; 12058 uriEl.textContent = displayUri; 12059 handleEl.textContent = `@${record.handle}`; 12060 typeEl.textContent = record.type; 12061 collectionEl.textContent = `computer.aesthetic.${record.collection}`; 12062 12063 if (record.type === 'mood') { 12064 centerEl.innerHTML = `<div class="at-record-mood">${escapeHtml(record.content)}</div>`; 12065 } else if (record.type === 'painting' && record.thumbnail) { 12066 centerEl.innerHTML = `<div class="at-record-thumb-wrap"><img class="at-record-thumb" src="${record.thumbnail}" alt="painting"></div>`; 12067 } else { 12068 centerEl.innerHTML = `<div class="at-record-mood">🎨</div>`; 12069 } 12070 } 12071 12072 function escapeHtml(text) { 12073 const div = document.createElement('div'); 12074 div.textContent = text; 12075 return div.innerHTML; 12076 } 12077 12078 setTimeout(loadATProtoRecords, 600); 12079 12080 // Linkify text - parse URLs, @handles, 'prompts', #paintings 12081 // Works on raw text, escapes only plain text segments 12082 function linkifyText(text) { 12083 // Parse elements with positions on RAW text (before escaping) 12084 const elements = []; 12085 12086 // URLs (http://, https://, www.) 12087 const urlRegex = /(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi; 12088 let match; 12089 while ((match = urlRegex.exec(text)) !== null) { 12090 elements.push({ type: 'url', text: match[0], start: match.index, end: match.index + match[0].length }); 12091 } 12092 12093 // @handles 12094 const handleRegex = /@[a-z0-9]+([._][a-z0-9]+)*/gi; 12095 while ((match = handleRegex.exec(text)) !== null) { 12096 // Don't match inside URLs 12097 const inUrl = elements.some(e => e.type === 'url' && match.index >= e.start && match.index < e.end); 12098 if (!inUrl) { 12099 elements.push({ type: 'handle', text: match[0], start: match.index, end: match.index + match[0].length }); 12100 } 12101 } 12102 12103 // 'prompts' (single-quoted words) 12104 const promptRegex = /'([^']+)'/g; 12105 while ((match = promptRegex.exec(text)) !== null) { 12106 const inUrl = elements.some(e => e.type === 'url' && match.index >= e.start && match.index < e.end); 12107 if (!inUrl) { 12108 elements.push({ type: 'prompt', text: match[0], inner: match[1], start: match.index, end: match.index + match[0].length }); 12109 } 12110 } 12111 12112 // #paintings (hashtag codes) 12113 const paintingRegex = /#[a-z0-9]+/gi; 12114 while ((match = paintingRegex.exec(text)) !== null) { 12115 const inUrl = elements.some(e => e.type === 'url' && match.index >= e.start && match.index < e.end); 12116 if (!inUrl) { 12117 elements.push({ type: 'painting', text: match[0], start: match.index, end: match.index + match[0].length }); 12118 } 12119 } 12120 12121 // Sort by position 12122 elements.sort((a, b) => a.start - b.start); 12123 12124 // Build result string - escape plain text, but not the HTML links we create 12125 let result = ''; 12126 let lastEnd = 0; 12127 12128 for (const el of elements) { 12129 // Add escaped text before this element 12130 result += escapeHtml(text.slice(lastEnd, el.start)); 12131 12132 // Add linked element (escape the display text) 12133 if (el.type === 'url') { 12134 const href = el.text.startsWith('www.') ? 'https://' + el.text : el.text; 12135 result += `<a href="${escapeHtml(href)}" class="chat-link-url" target="_blank" rel="noopener">${escapeHtml(el.text)}</a>`; 12136 } else if (el.type === 'handle') { 12137 result += `<a href="https://aesthetic.computer/${escapeHtml(el.text)}" class="chat-link-handle" target="_blank">${escapeHtml(el.text)}</a>`; 12138 } else if (el.type === 'prompt') { 12139 result += `<a href="https://aesthetic.computer/${escapeHtml(el.inner)}" class="chat-link-prompt" target="_blank">${escapeHtml(el.text)}</a>`; 12140 } else if (el.type === 'painting') { 12141 const code = el.text.slice(1); // Remove # 12142 result += `<a href="https://aesthetic.computer/painting~${escapeHtml(code)}" class="chat-link-painting" target="_blank">${escapeHtml(el.text)}</a>`; 12143 } 12144 12145 lastEnd = el.end; 12146 } 12147 12148 // Add remaining escaped text 12149 result += escapeHtml(text.slice(lastEnd)); 12150 12151 return result; 12152 } 12153 12154 // Extract painting codes from text and generate thumbnail HTML 12155 function getPaintingThumbnails(text) { 12156 const codes = []; 12157 const paintingRegex = /#([a-z0-9]+)/gi; 12158 let match; 12159 while ((match = paintingRegex.exec(text)) !== null) { 12160 codes.push(match[1]); 12161 } 12162 if (!codes.length) return ''; 12163 12164 const thumbs = codes.map(code => ` 12165 <div class="chat-painting-thumb"> 12166 <a href="https://aesthetic.computer/painting~${escapeHtml(code)}" target="_blank"> 12167 <img 12168 src="https://art.aesthetic.computer/${escapeHtml(code)}.png" 12169 alt="#${escapeHtml(code)}" 12170 loading="lazy" 12171 onload="this.classList.add('loaded')" 12172 onerror="this.parentElement.parentElement.style.display='none'" 12173 > 12174 <span class="thumb-code">#${escapeHtml(code)}</span> 12175 </a> 12176 </div> 12177 `).join(''); 12178 12179 return `<div class="chat-painting-thumbs">${thumbs}</div>`; 12180 } 12181 12182 // Chat Messages - Læer-Klokken messages (scrolling list) 12183 async function loadChatMessages() { 12184 const container = document.getElementById('chatMessages'); 12185 try { 12186 const res = await fetch(`${apiBase}/api/chat/messages?instance=clock&limit=25`); 12187 const data = await res.json(); 12188 const messages = data.messages || []; 12189 12190 if (!messages.length) { 12191 container.innerHTML = '<div class="chat-msg"><div class="chat-text" style="color: var(--dim);">No messages yet</div></div>'; 12192 return; 12193 } 12194 12195 // Messages come oldest-first from API, we want newest at top 12196 const html = messages.map(msg => { 12197 const when = new Date(msg.when); 12198 const ago = formatTimeAgo(when); 12199 // API returns 'from' field with '@' prefix if handle exists 12200 const handle = msg.from || 'anon'; 12201 const thumbs = getPaintingThumbnails(msg.text || ''); 12202 return ` 12203 <div class="chat-msg"> 12204 <div class="chat-msg-header"> 12205 <a href="https://aesthetic.computer/${handle}" class="chat-handle">${handle}</a> 12206 <span class="chat-time">${ago}</span> 12207 </div> 12208 <div class="chat-text">${linkifyText(msg.text || '')}</div> 12209 ${thumbs} 12210 </div> 12211 `; 12212 }).reverse().join(''); 12213 12214 container.innerHTML = html; 12215 12216 // Mark chat panel ready 12217 panelSync.markReady('chat'); 12218 12219 } catch (e) { 12220 console.error('Chat load error:', e); 12221 if (SERVER_SUSPENDED) { 12222 renderSuspended(container, 'card'); 12223 } else { 12224 const loadErrorTexts = { en: 'Could not load', da: 'Kunne ikke indlæse', de: 'Konnte nicht laden', es: 'No se pudo cargar', zh: '无法加载' }; 12225 container.innerHTML = `<div class="chat-msg"><div class="chat-text" style="color: var(--dim);">${loadErrorTexts[currentLang] || loadErrorTexts.en}</div></div>`; 12226 } 12227 panelSync.markReady('chat'); // Still mark ready on error 12228 } 12229 } 12230 12231 function formatTimeAgo(date) { 12232 const now = new Date(); 12233 const diff = now - date; 12234 const mins = Math.floor(diff / 60000); 12235 const hours = Math.floor(diff / 3600000); 12236 const days = Math.floor(diff / 86400000); 12237 12238 // Translated time ago strings 12239 const timeAgoTexts = { 12240 en: { justNow: 'just now', mAgo: 'm ago', hAgo: 'h ago', dAgo: 'd ago' }, 12241 da: { justNow: 'lige nu', mAgo: 'm siden', hAgo: 't siden', dAgo: 'd siden' }, 12242 de: { justNow: 'gerade', mAgo: 'm her', hAgo: 'h her', dAgo: 'T her' }, 12243 es: { justNow: 'ahora', mAgo: 'm', hAgo: 'h', dAgo: 'd' }, 12244 zh: { justNow: '刚刚', mAgo: '分钟前', hAgo: '小时前', dAgo: '天前' } 12245 }; 12246 const ta = timeAgoTexts[currentLang] || timeAgoTexts.en; 12247 12248 if (mins < 1) return ta.justNow; 12249 if (mins < 60) return `${mins}${ta.mAgo}`; 12250 if (hours < 24) return `${hours}${ta.hAgo}`; 12251 if (days < 7) return `${days}${ta.dAgo}`; 12252 12253 // Use locale-appropriate date format 12254 const locales = { en: 'en-US', da: 'da-DK', de: 'de-DE', es: 'es-ES', zh: 'zh-CN' }; 12255 return date.toLocaleDateString(locales[currentLang] || 'en-US'); 12256 } 12257 12258 function escapeHtml(str) { 12259 return str.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 12260 } 12261 12262 // Unified module loading helpers 12263 function showModuleLoading(containerId, text = 'loading') { 12264 const container = document.getElementById(containerId); 12265 if (!container) return; 12266 container.innerHTML = `<div class="module-loading">${escapeHtml(text)}</div>`; 12267 } 12268 12269 function hideModuleLoading(containerId) { 12270 const container = document.getElementById(containerId); 12271 if (!container) return; 12272 const loader = container.querySelector('.module-loading'); 12273 if (loader) loader.remove(); 12274 } 12275 12276 setTimeout(loadChatMessages, 600); 12277 12278 // Auto-scroll chat messages 12279 function autoScrollChat(containerId, speed = 0.5) { 12280 const container = document.getElementById(containerId); 12281 if (!container) return; 12282 12283 let scrollPos = 0; 12284 let isPaused = false; 12285 12286 // Pause on hover 12287 container.addEventListener('mouseenter', () => isPaused = true); 12288 container.addEventListener('mouseleave', () => isPaused = false); 12289 12290 function scroll() { 12291 if (!isPaused && container.scrollHeight > container.clientHeight) { 12292 scrollPos += speed; 12293 // Reset to top when reaching bottom 12294 if (scrollPos >= container.scrollHeight - container.clientHeight) { 12295 scrollPos = 0; 12296 } 12297 container.scrollTop = scrollPos; 12298 } 12299 requestAnimationFrame(scroll); 12300 } 12301 requestAnimationFrame(scroll); 12302 } 12303 12304 // Start auto-scroll for both chat containers at different speeds 12305 setTimeout(() => { 12306 autoScrollChat('chatMessages', 0.25); // laer-klokken: slower 12307 autoScrollChat('chatSystemMessages', 0.35); // chat: slightly faster 12308 }, 2000); // Wait for content to load 12309 12310 // Chat System Messages (back face of flip card) 12311 async function loadChatSystemMessages() { 12312 const container = document.getElementById('chatSystemMessages'); 12313 if (!container) return; 12314 try { 12315 const res = await fetch(`${apiBase}/api/chat/messages?instance=system&limit=25`); 12316 const data = await res.json(); 12317 const messages = data.messages || []; 12318 12319 if (!messages.length) { 12320 container.innerHTML = '<div class="chat-msg"><div class="chat-text" style="color: var(--dim);">No messages yet</div></div>'; 12321 return; 12322 } 12323 12324 // Messages come oldest-first from API, we want newest at top 12325 const html = messages.map(msg => { 12326 const when = new Date(msg.when); 12327 const ago = formatTimeAgo(when); 12328 const handle = msg.from || 'anon'; 12329 const thumbs = getPaintingThumbnails(msg.text || ''); 12330 return ` 12331 <div class="chat-msg"> 12332 <div class="chat-msg-header"> 12333 <a href="https://aesthetic.computer/${handle}" class="chat-handle">${handle}</a> 12334 <span class="chat-time">${ago}</span> 12335 </div> 12336 <div class="chat-text">${linkifyText(msg.text || '')}</div> 12337 ${thumbs} 12338 </div> 12339 `; 12340 }).reverse().join(''); 12341 12342 container.innerHTML = html; 12343 12344 } catch (e) { 12345 console.error('Chat system load error:', e); 12346 const loadErrorTexts = { en: 'Could not load', da: 'Kunne ikke indlæse', de: 'Konnte nicht laden', es: 'No se pudo cargar', zh: '无法加载' }; 12347 container.innerHTML = `<div class="chat-msg"><div class="chat-text" style="color: var(--dim);">${loadErrorTexts[currentLang] || loadErrorTexts.en}</div></div>`; 12348 } 12349 } 12350 12351 setTimeout(loadChatSystemMessages, 700); 12352 12353 // Flip card toggle function with direction support 12354 function toggleChatFlip(direction) { 12355 const card = document.getElementById('chatFlipCard'); 12356 const container = document.querySelector('.chat-flip-container'); 12357 if (card && container) { 12358 const isFlipped = card.classList.contains('flipped'); 12359 // If direction specified, only flip if it makes sense 12360 if (direction === 'left' && isFlipped) { 12361 card.classList.remove('flipped'); 12362 container.classList.remove('flipped'); 12363 resetFlipProgress(); 12364 } else if (direction === 'right' && !isFlipped) { 12365 card.classList.add('flipped'); 12366 container.classList.add('flipped'); 12367 resetFlipProgress(); 12368 } else if (!direction) { 12369 card.classList.toggle('flipped'); 12370 container.classList.toggle('flipped'); 12371 resetFlipProgress(); 12372 } 12373 } 12374 } 12375 // Expose to global scope for onclick 12376 window.toggleChatFlip = toggleChatFlip; 12377 12378 // Hover tilt effect for swivel preview 12379 function setupHoverTilt() { 12380 const container = document.querySelector('.chat-flip-container'); 12381 const card = document.getElementById('chatFlipCard'); 12382 if (!container || !card) return; 12383 12384 container.addEventListener('mousemove', (e) => { 12385 if (card.classList.contains('flipped')) return; // Don't tilt when flipped 12386 const rect = container.getBoundingClientRect(); 12387 const x = e.clientX - rect.left; 12388 const width = rect.width; 12389 const third = width / 3; 12390 12391 card.classList.remove('tilt-left', 'tilt-right'); 12392 if (x < third) { 12393 card.classList.add('tilt-left'); 12394 } else if (x > third * 2) { 12395 card.classList.add('tilt-right'); 12396 } 12397 }); 12398 12399 container.addEventListener('mouseleave', () => { 12400 card.classList.remove('tilt-left', 'tilt-right'); 12401 }); 12402 12403 // Click zones for directional swivel 12404 container.addEventListener('click', (e) => { 12405 // Don't trigger if clicking on a link or explicit trigger 12406 if (e.target.closest('a') || e.target.closest('.chat-flip-trigger')) return; 12407 12408 const rect = container.getBoundingClientRect(); 12409 const x = e.clientX - rect.left; 12410 const width = rect.width; 12411 const isFlipped = card.classList.contains('flipped'); 12412 12413 if (x < width / 2) { 12414 // Left side - swivel to show front (unflip) 12415 if (isFlipped) toggleChatFlip('left'); 12416 } else { 12417 // Right side - swivel to show back (flip) 12418 if (!isFlipped) toggleChatFlip('right'); 12419 } 12420 }); 12421 } 12422 setTimeout(setupHoverTilt, 100); 12423 12424 // Auto-flip with progress bar 12425 const FLIP_INTERVAL = 8000; // 8 seconds between flips (synced with other panels) 12426 let flipProgress = 0; 12427 let flipPaused = false; 12428 12429 function resetFlipProgress() { 12430 flipProgress = 0; 12431 const bar = document.getElementById('chatFlipProgress'); 12432 const barBack = document.getElementById('chatFlipProgressBack'); 12433 if (bar) bar.style.width = '0%'; 12434 if (barBack) barBack.style.width = '0%'; 12435 } 12436 12437 function updateFlipProgress() { 12438 const bar = document.getElementById('chatFlipProgress'); 12439 const barBack = document.getElementById('chatFlipProgressBack'); 12440 const card = document.getElementById('chatFlipCard'); 12441 const container = document.querySelector('.chat-flip-container'); 12442 const isFlipped = card && card.classList.contains('flipped'); 12443 12444 // Pause on hover 12445 if (container && !container.dataset.hoverBound) { 12446 container.dataset.hoverBound = 'true'; 12447 container.addEventListener('mouseenter', () => flipPaused = true); 12448 container.addEventListener('mouseleave', () => flipPaused = false); 12449 } 12450 12451 if (!flipPaused) { 12452 flipProgress += 100 / (FLIP_INTERVAL / 100); // Update every 100ms 12453 // Only update the visible face's progress bar 12454 if (isFlipped) { 12455 if (barBack) barBack.style.width = flipProgress + '%'; 12456 if (bar) bar.style.width = '0%'; 12457 } else { 12458 if (bar) bar.style.width = flipProgress + '%'; 12459 if (barBack) barBack.style.width = '0%'; 12460 } 12461 12462 if (flipProgress >= 100) { 12463 toggleChatFlip(); 12464 // resetFlipProgress is called inside toggleChatFlip 12465 } 12466 } 12467 12468 setTimeout(updateFlipProgress, 100); 12469 } 12470 12471 // Register chat flip starter with sync controller (don't start immediately) 12472 panelSync.registerStarter(() => updateFlipProgress()); 12473 12474 // WebP/Image Dud Validator - Checks pixel data to detect blank/corrupted animations 12475 // For animated webps, samples multiple frames over time to avoid false positives 12476 // when first frame is blank (common with "(wipe white)" starts) 12477 // Returns a promise that resolves to { valid: bool, reason: string, samples: array } 12478 async function validateWebpImage(img, code = 'unknown') { 12479 return new Promise(async (resolve) => { 12480 // Create offscreen canvas to sample pixels 12481 const canvas = document.createElement('canvas'); 12482 const ctx = canvas.getContext('2d', { willReadFrequently: true }); 12483 12484 // Sample at a reasonable size (don't need full resolution) 12485 const sampleSize = Math.min(64, img.naturalWidth, img.naturalHeight); 12486 canvas.width = sampleSize; 12487 canvas.height = sampleSize; 12488 12489 // For animated webps, sample multiple frames over time 12490 // This avoids false positives when early frames are uniform 12491 const NUM_FRAMES = 5; 12492 const FRAME_DELAY = 150; // ms between frame samples 12493 let anyValidFrame = false; 12494 let lastResult = null; 12495 12496 for (let frameNum = 0; frameNum < NUM_FRAMES; frameNum++) { 12497 if (frameNum > 0) { 12498 await new Promise(r => setTimeout(r, FRAME_DELAY)); 12499 } 12500 12501 lastResult = await sampleImageOnce(ctx, canvas, img, sampleSize, code, frameNum, NUM_FRAMES); 12502 12503 if (lastResult.valid) { 12504 anyValidFrame = true; 12505 break; // Found a good frame with varied content, accept it 12506 } 12507 } 12508 12509 if (anyValidFrame) { 12510 resolve(lastResult); 12511 } else { 12512 // All frames were duds (uniform/transparent) - report the last result 12513 resolve(lastResult); 12514 } 12515 }); 12516 } 12517 12518 // Helper to sample image pixels once - checks many points for uniformity 12519 async function sampleImageOnce(ctx, canvas, img, sampleSize, code, frameNum, totalFrames) { 12520 return new Promise((resolve) => { 12521 try { 12522 ctx.clearRect(0, 0, sampleSize, sampleSize); 12523 ctx.drawImage(img, 0, 0, sampleSize, sampleSize); 12524 const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize); 12525 const pixels = imageData.data; 12526 12527 // Sample many points across the image for better coverage 12528 // Grid pattern + corners + diagonals = 25 points 12529 const samplePoints = []; 12530 12531 // 4x4 grid (16 points) 12532 for (let gy = 0; gy < 4; gy++) { 12533 for (let gx = 0; gx < 4; gx++) { 12534 const x = Math.floor(sampleSize * (0.1 + gx * 0.27)); 12535 const y = Math.floor(sampleSize * (0.1 + gy * 0.27)); 12536 samplePoints.push({ x, y, name: `grid-${gx}-${gy}` }); 12537 } 12538 } 12539 12540 // Corners (4 points) 12541 samplePoints.push({ x: 2, y: 2, name: 'corner-tl' }); 12542 samplePoints.push({ x: sampleSize - 3, y: 2, name: 'corner-tr' }); 12543 samplePoints.push({ x: 2, y: sampleSize - 3, name: 'corner-bl' }); 12544 samplePoints.push({ x: sampleSize - 3, y: sampleSize - 3, name: 'corner-br' }); 12545 12546 // Center cross (5 points) 12547 const mid = Math.floor(sampleSize / 2); 12548 samplePoints.push({ x: mid, y: mid, name: 'center' }); 12549 samplePoints.push({ x: mid, y: Math.floor(sampleSize * 0.2), name: 'center-top' }); 12550 samplePoints.push({ x: mid, y: Math.floor(sampleSize * 0.8), name: 'center-bot' }); 12551 samplePoints.push({ x: Math.floor(sampleSize * 0.2), y: mid, name: 'center-left' }); 12552 samplePoints.push({ x: Math.floor(sampleSize * 0.8), y: mid, name: 'center-right' }); 12553 12554 const samples = []; 12555 let allTransparent = true; 12556 let firstColor = null; 12557 let maxColorDiff = 0; 12558 12559 for (const pt of samplePoints) { 12560 const idx = (pt.y * sampleSize + pt.x) * 4; 12561 const r = pixels[idx]; 12562 const g = pixels[idx + 1]; 12563 const b = pixels[idx + 2]; 12564 const a = pixels[idx + 3]; 12565 12566 samples.push({ ...pt, r, g, b, a }); 12567 12568 // Check transparency 12569 if (a > 10) allTransparent = false; 12570 12571 // Track color variance 12572 if (firstColor === null) { 12573 firstColor = { r, g, b, a }; 12574 } else { 12575 const colorDiff = Math.abs(r - firstColor.r) + Math.abs(g - firstColor.g) + 12576 Math.abs(b - firstColor.b); 12577 maxColorDiff = Math.max(maxColorDiff, colorDiff); 12578 } 12579 } 12580 12581 // Determine if it's a dud - key metric is color variance 12582 // If all 25 sample points are within 30 total RGB difference, it's uniform 12583 const isUniform = maxColorDiff < 30; 12584 let valid = true; 12585 let reason = 'OK'; 12586 12587 if (allTransparent) { 12588 valid = false; 12589 reason = 'ALL_TRANSPARENT'; 12590 } else if (isUniform && firstColor) { 12591 // Uniform/solid color = dud (whether black, white, or any color) 12592 valid = false; 12593 const hex = `#${firstColor.r.toString(16).padStart(2,'0')}${firstColor.g.toString(16).padStart(2,'0')}${firstColor.b.toString(16).padStart(2,'0')}`; 12594 reason = `UNIFORM_COLOR:${hex}`; 12595 } 12596 12597 12598 resolve({ valid, reason, samples, code, maxColorDiff }); 12599 12600 } catch (e) { 12601 console.error(`%c🚨 WEBP VALIDATE ERROR: $${code}`, 'color: red; font-size: 16px;', e); 12602 resolve({ valid: false, reason: 'CANVAS_ERROR', error: e.message, code }); 12603 } 12604 }); 12605 } 12606 12607 async function loadKidlispCarousel() { 12608 const carousel = document.getElementById('kidlispCarousel'); 12609 try { 12610 // Fetch top hits from the TV API 12611 const res = await fetch(`${apiBase}/api/tv?types=kidlisp&sort=hits&limit=100`); 12612 const data = await res.json(); 12613 const allHits = data.media?.kidlisp || data.mixed || []; 12614 12615 // Filter to only show @jeffrey pieces or anonymous top hits 12616 const hits = allHits.filter(item => { 12617 const handle = item.owner?.handle?.replace('@', '') || item.handle || item.author || ''; 12618 // Show @jeffrey pieces or anonymous pieces (no handle/anon) 12619 return handle === 'jeffrey' || !handle || handle === 'anon'; 12620 }); 12621 12622 if (!hits.length) { 12623 carousel.innerHTML = '<div style="text-align: center; color: #555; padding: 2em;">No pieces found</div>'; 12624 return; 12625 } 12626 12627 // Shuffle for variety using Fisher-Yates 12628 function shuffle(arr) { 12629 const a = [...arr]; 12630 for (let i = a.length - 1; i > 0; i--) { 12631 const j = Math.floor(Math.random() * (i + 1)); 12632 [a[i], a[j]] = [a[j], a[i]]; 12633 } 12634 return a; 12635 } 12636 const shuffledHits = shuffle(hits); 12637 12638 // KidLisp syntax highlighter - matches kidlisp.com/index.html 12639 function highlightKidlisp(code) { 12640 if (!code) return ''; 12641 // Escape HTML first 12642 let html = code 12643 .replace(/&/g, '&amp;') 12644 .replace(/</g, '&lt;') 12645 .replace(/>/g, '&gt;'); 12646 12647 // Full CSS color map (from num.mjs cssColors) 12648 const cssColors = { 12649 aliceblue: [240, 248, 255], antiquewhite: [250, 235, 215], aqua: [0, 255, 255], 12650 aquamarine: [127, 255, 212], azure: [240, 255, 255], beige: [245, 245, 220], 12651 bisque: [255, 228, 196], black: [0, 0, 0], blanchedalmond: [255, 235, 205], 12652 blue: [0, 0, 255], blueviolet: [138, 43, 226], brown: [165, 42, 42], 12653 burlywood: [222, 184, 135], cadetblue: [95, 158, 160], chartreuse: [127, 255, 0], 12654 chocolate: [210, 105, 30], coral: [255, 127, 80], cornflowerblue: [100, 149, 237], 12655 cornsilk: [255, 248, 220], crimson: [220, 20, 60], cyan: [0, 255, 255], 12656 darkblue: [0, 0, 139], darkcyan: [0, 139, 139], darkgoldenrod: [184, 134, 11], 12657 darkgray: [169, 169, 169], darkgrey: [169, 169, 169], darkgreen: [0, 100, 0], 12658 darkkhaki: [189, 183, 107], darkmagenta: [139, 0, 139], darkolivegreen: [85, 107, 47], 12659 darkorange: [255, 140, 0], darkorchid: [153, 50, 204], darkred: [139, 0, 0], 12660 darksalmon: [233, 150, 122], darkseagreen: [143, 188, 143], darkslateblue: [72, 61, 139], 12661 darkslategray: [47, 79, 79], darkslategrey: [47, 79, 79], darkturquoise: [0, 206, 209], 12662 darkviolet: [148, 0, 211], deeppink: [255, 20, 147], deepskyblue: [0, 191, 255], 12663 dimgray: [105, 105, 105], dimgrey: [105, 105, 105], dodgerblue: [30, 144, 255], 12664 firebrick: [178, 34, 34], floralwhite: [255, 250, 240], forestgreen: [34, 139, 34], 12665 fuchsia: [255, 0, 255], gainsboro: [220, 220, 220], ghostwhite: [248, 248, 255], 12666 gold: [255, 215, 0], goldenrod: [218, 165, 32], gray: [128, 128, 128], 12667 grey: [128, 128, 128], green: [0, 128, 0], greenyellow: [173, 255, 47], 12668 honeydew: [240, 255, 240], hotpink: [255, 105, 180], indianred: [205, 92, 92], 12669 indigo: [75, 0, 130], ivory: [255, 255, 240], khaki: [240, 230, 140], 12670 lavender: [230, 230, 250], lavenderblush: [255, 240, 245], lawngreen: [124, 252, 0], 12671 lemonchiffon: [255, 250, 205], lightblue: [173, 216, 230], lightcoral: [240, 128, 128], 12672 lightcyan: [224, 255, 255], lightgoldenrodyellow: [250, 250, 210], lightgray: [211, 211, 211], 12673 lightgrey: [211, 211, 211], lightgreen: [144, 238, 144], lightpink: [255, 182, 193], 12674 lightsalmon: [255, 160, 122], lightseagreen: [32, 178, 170], lightskyblue: [135, 206, 250], 12675 lightslategray: [119, 136, 153], lightslategrey: [119, 136, 153], lightsteelblue: [176, 196, 222], 12676 lightyellow: [255, 255, 224], lime: [0, 255, 0], limegreen: [50, 205, 50], 12677 linen: [250, 240, 230], magenta: [255, 0, 255], maroon: [128, 0, 0], 12678 mediumaquamarine: [102, 205, 170], mediumblue: [0, 0, 205], mediumorchid: [186, 85, 211], 12679 mediumpurple: [147, 112, 219], mediumseagreen: [60, 179, 113], mediumslateblue: [123, 104, 238], 12680 mediumspringgreen: [0, 250, 154], mediumturquoise: [72, 209, 204], mediumvioletred: [199, 21, 133], 12681 midnightblue: [25, 25, 112], mintcream: [245, 255, 250], mistyrose: [255, 228, 225], 12682 moccasin: [255, 228, 181], navajowhite: [255, 222, 173], navy: [0, 0, 128], 12683 oldlace: [253, 245, 230], olive: [128, 128, 0], olivedrab: [107, 142, 35], 12684 orange: [255, 165, 0], orangered: [255, 69, 0], orchid: [218, 112, 214], 12685 palegoldenrod: [238, 232, 170], palegreen: [152, 251, 152], paleturquoise: [175, 238, 238], 12686 palevioletred: [219, 112, 147], papayawhip: [255, 239, 213], peachpuff: [255, 218, 185], 12687 peru: [205, 133, 63], pink: [255, 192, 203], plum: [221, 160, 221], 12688 powderblue: [176, 224, 230], purple: [128, 0, 128], rebeccapurple: [102, 51, 153], 12689 red: [255, 0, 0], rosybrown: [188, 143, 143], royalblue: [65, 105, 225], 12690 saddlebrown: [139, 69, 19], salmon: [250, 128, 114], sandybrown: [244, 164, 96], 12691 seagreen: [46, 139, 87], seashell: [255, 245, 238], sienna: [160, 82, 45], 12692 silver: [192, 192, 192], skyblue: [135, 206, 235], slateblue: [106, 90, 205], 12693 slategray: [112, 128, 144], slategrey: [112, 128, 144], snow: [255, 250, 250], 12694 springgreen: [0, 255, 127], steelblue: [70, 130, 180], tan: [210, 180, 140], 12695 teal: [0, 128, 128], thistle: [216, 191, 216], tomato: [255, 99, 71], 12696 turquoise: [64, 224, 208], violet: [238, 130, 238], wheat: [245, 222, 179], 12697 white: [255, 255, 255], whitesmoke: [245, 245, 245], yellow: [255, 255, 0], 12698 yellowgreen: [154, 205, 50], darkbrown: [101, 67, 33], darkerbrown: [62, 39, 35], 12699 darksienna: [139, 90, 43] 12700 }; 12701 12702 // Helper to create rainbow-highlighted text (per-character animation) 12703 function rainbowText(text) { 12704 return text.split('').map((char, i) => 12705 `<span class="hl-rainbow hl-rainbow-${i % 7}">${char}</span>` 12706 ).join(''); 12707 } 12708 12709 // Helper to create zebra-highlighted text (alternating black/white) 12710 function zebraText(text) { 12711 return text.split('').map((char, i) => 12712 `<span class="hl-zebra hl-zebra-${i % 2}">${char}</span>` 12713 ).join(''); 12714 } 12715 12716 // Helper to create fade-highlighted text (color gradient indicator) 12717 function fadeText(fadeStr) { 12718 // Parse fade string like "fade:red-blue" or "fade:red-white-blue:down" 12719 const parts = fadeStr.split(':'); 12720 if (parts.length < 2) return `<span class="hl-fade">${fadeStr}</span>`; 12721 const colors = parts[1].split('-'); 12722 const direction = parts[2] || ''; 12723 12724 // Create colored segments for each color in the fade 12725 let result = '<span class="hl-fade" style="color: #ffb86c;">fade</span><span class="hl-fade-colon">:</span>'; 12726 colors.forEach((c, i) => { 12727 const rgb = cssColors[c.toLowerCase()]; 12728 if (rgb) { 12729 result += `<span class="hl-color" style="color: rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})">${c}</span>`; 12730 } else if (c === 'rainbow') { 12731 result += rainbowText(c); 12732 } else if (c === 'zebra') { 12733 result += zebraText(c); 12734 } else { 12735 result += `<span class="hl-fade">${c}</span>`; 12736 } 12737 if (i < colors.length - 1) result += '<span class="hl-fade-sep">-</span>'; 12738 }); 12739 if (direction) { 12740 result += `<span class="hl-fade-colon">:</span><span class="hl-fade-dir">${direction}</span>`; 12741 } 12742 return result; 12743 } 12744 12745 // Helper to highlight $code references (lime green $ + lime identifier) 12746 function codeRefText(codeRef) { 12747 const dollar = codeRef[0]; 12748 const id = codeRef.slice(1); 12749 return `<span class="hl-code-ref">${dollar}</span><span class="hl-code-id">${id}</span>`; 12750 } 12751 12752 // Helper to highlight #painting references (magenta # + orange identifier) 12753 function paintRefText(paintRef) { 12754 const hash = paintRef[0]; 12755 const id = paintRef.slice(1); 12756 return `<span class="hl-paint-ref">${hash}</span><span class="hl-paint-id">${id}</span>`; 12757 } 12758 12759 // Use token placeholders to prevent double-replacement 12760 // Use \x01T{idx}T\x01 format to avoid number regex matching the idx 12761 const tokens = []; 12762 function token(match, cls, style) { 12763 const idx = tokens.length; 12764 const styleAttr = style ? ` style="${style}"` : ''; 12765 tokens.push(`<span class="${cls}"${styleAttr}>${match}</span>`); 12766 return `\x01T${idx}T\x01`; 12767 } 12768 12769 function tokenRaw(html) { 12770 const idx = tokens.length; 12771 tokens.push(html); 12772 return `\x01T${idx}T\x01`; 12773 } 12774 12775 // Order matters - most specific first 12776 12777 // $code references (e.g., $mycode, $0, $test123) - limegreen $ + lime id 12778 html = html.replace(/(\$[0-9A-Za-z]+)/g, m => tokenRaw(codeRefText(m))); 12779 12780 // #painting references (e.g., #abc123, #a1b2c3d4) - magenta # + orange id 12781 html = html.replace(/(#[0-9A-Fa-f]{1,8})\b/g, m => tokenRaw(paintRefText(m))); 12782 12783 // Fade patterns (e.g., fade:red-blue, fade:red-white-blue:down) 12784 html = html.replace(/\b(fade:[a-z0-9-]+(?::[a-z]+)?)\b/gi, m => tokenRaw(fadeText(m))); 12785 12786 // Rainbow keyword - animate the word itself 12787 html = html.replace(/\b(rainbow)\b/g, m => tokenRaw(rainbowText(m))); 12788 12789 // Zebra keyword - animate the word itself 12790 html = html.replace(/\b(zebra)\b/g, m => tokenRaw(zebraText(m))); 12791 12792 // Comments (lines starting with ;) 12793 html = html.replace(/(^|\n)(;[^\n]*)/g, (m, pre, comment) => pre + token(comment, 'hl-comment')); 12794 // Timing patterns - match kidlisp.mjs exactly: 12795 // 1. Cycle timers: 2s..., 3f..., 0.5s... (number + s/f + one or more dots) 12796 // 2. Delay timers: 1s, 0.5s, 3f, 1s! (number + s/f + optional !) 12797 // Match from start of token (after space or open paren) to avoid partial matches 12798 // Include data-duration for dynamic animation timing 12799 html = html.replace(/(^|[\s(])(\d*\.?\d+)([sf](\.{1,3}|!?))/g, (m, pre, num, suffix) => { 12800 const full = num + suffix; 12801 const unit = suffix[0]; // 's' or 'f' 12802 // Convert to seconds (frames assume 60fps) 12803 const duration = unit === 's' ? parseFloat(num) : parseFloat(num) / 60; 12804 const idx = tokens.length; 12805 tokens.push(`<span class="hl-timing hl-timing-active" data-duration="${duration}">${full}</span>`); 12806 return pre + `\x01T${idx}T\x01`; 12807 }); 12808 // Strings 12809 html = html.replace(/("[^"]*")/g, m => token(m, 'hl-string')); 12810 // Numbers (now safe since timing already tokenized) 12811 html = html.replace(/\b(\d+\.?\d*)\b/g, m => token(m, 'hl-number')); 12812 // Keywords (control flow) 12813 const keywords = ['def', 'fn', 'if', 'cond', 'let', 'loop', 'repeat', 'later', 'tap', 'drag', 'lift', 'key', 'draw', 'frame', 'now', 'once']; 12814 keywords.forEach(kw => { 12815 html = html.replace(new RegExp(`\\b(${kw})\\b`, 'g'), m => token(m, 'hl-keyword')); 12816 }); 12817 // API calls (drawing/commands) - but not rainbow/zebra (already handled) 12818 const apiCalls = ['wipe', 'ink', 'line', 'box', 'circle', 'text', 'pan', 'zoom', 'blur', 'noise', 'width', 'height', 'wiggle', 'grid', 'plot', 'stamp', 'scroll', 'contrast', 'fps']; 12819 apiCalls.forEach(api => { 12820 html = html.replace(new RegExp(`\\b(${api})\\b`, 'g'), m => token(m, 'hl-api')); 12821 }); 12822 // Colors - use actual color values from cssColors (skip rainbow/zebra as they're special) 12823 const colorNames = Object.keys(cssColors).filter(c => c !== 'rainbow' && c !== 'zebra'); 12824 colorNames.forEach(colorName => { 12825 const rgb = cssColors[colorName]; 12826 const colorStyle = `color: rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; 12827 html = html.replace(new RegExp(`\\b(${colorName})\\b`, 'gi'), m => token(m, 'hl-color', colorStyle)); 12828 }); 12829 // Parens 12830 html = html.replace(/([()])/g, m => token(m, 'hl-paren')); 12831 12832 // Replace tokens back 12833 html = html.replace(/\x01T(\d+)T\x01/g, (_, idx) => tokens[parseInt(idx)]); 12834 return html; 12835 } 12836 12837 // Preferred starter codes (nice spirals for page load) - one will always be first 12838 const STARTER_CODES = ['otoc', 'roz', 'cow', 'air']; 12839 const starterCode = STARTER_CODES[Math.floor(Math.random() * STARTER_CODES.length)]; 12840 12841 // Find starter data in the full hits array 12842 let starterItem = hits.find(item => item.code === starterCode); 12843 12844 // If not in hits, create minimal item (webp will still load from oven) 12845 if (!starterItem) { 12846 starterItem = { code: starterCode, hits: 0, source: '', owner: { handle: '@jeffrey' } }; 12847 } 12848 12849 // Build slide list: starter first, then shuffled (excluding starter to avoid dupe) 12850 const filteredHits = shuffledHits.filter(item => item.code !== starterCode); 12851 const slides = [starterItem, ...filteredHits.slice(0, 19)]; 12852 12853 let currentSlide = 0; 12854 let slideInterval = null; 12855 12856 // Format date with time (e.g., "Jan. 5 @ 3:42pm") 12857 function formatDateTime(when) { 12858 if (!when) return ''; 12859 const date = new Date(when); 12860 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 12861 const month = months[date.getMonth()]; 12862 const day = date.getDate(); 12863 const year = date.getFullYear(); 12864 const currentYear = new Date().getFullYear(); 12865 12866 // Format time as 12-hour with am/pm 12867 let hours = date.getHours(); 12868 const mins = date.getMinutes().toString().padStart(2, '0'); 12869 const ampm = hours >= 12 ? 'pm' : 'am'; 12870 hours = hours % 12 || 12; 12871 const timeStr = `${hours}:${mins}${ampm}`; 12872 12873 // Show year if not current year 12874 if (year !== currentYear) { 12875 return `${month}. ${day} '${String(year).slice(-2)} @ ${timeStr}`; 12876 } 12877 return `${month}. ${day} @ ${timeStr}`; 12878 } 12879 12880 carousel.innerHTML = slides.map((item, i) => { 12881 const code = item.code || 'unknown'; 12882 const hitCount = item.hits || 0; 12883 const source = item.source || ''; 12884 const author = item.owner?.handle?.replace('@', '') || item.handle || item.author || ''; 12885 const when = item.when; 12886 const pieceUrl = `${kidlispUrl}/$${code}`; 12887 12888 // Don't truncate - let scrolling handle long code 12889 const highlighted = highlightKidlisp(source); 12890 12891 // Build author/time credit line with colored spans 12892 let creditHtml = ''; 12893 if (author && author !== 'anon') { 12894 const dateStr = when ? `<span class="credit-date"> · ${formatDateTime(when)}</span>` : ''; 12895 creditHtml = `<div class="kidlisp-slide-credit"><span class="credit-handle">@${escapeHtml(author)}</span>${dateStr}</div>`; 12896 } else if (when) { 12897 creditHtml = `<div class="kidlisp-slide-credit"><span class="credit-date">${formatDateTime(when)}</span></div>`; 12898 } 12899 12900 // Wrap code chars in spans for animation 12901 const codeChars = ('$' + escapeHtml(code)).split('').map(c => `<span class="code-char">${c}</span>`).join(''); 12902 12903 return ` 12904 <div class="kidlisp-slide" data-index="${i}" data-code="${escapeHtml(code)}" data-hits="${hitCount}" data-source="${escapeHtml(source).replace(/"/g, '&quot;')}"> 12905 <a href="${pieceUrl}" target="_blank" title="Play $${escapeHtml(code)}"> 12906 <div class="module-loading"></div> 12907 </a> 12908 <div class="kidlisp-slide-code"><div class="kidlisp-slide-code-inner">${highlighted}</div></div> 12909 <div class="kidlisp-slide-qr-wrap"> 12910 <div class="kidlisp-slide-label">${codeChars}</div> 12911 <div class="kidlisp-slide-qr" data-url="${pieceUrl}"></div> 12912 </div> 12913 <div class="kidlisp-slide-plays">${hitCount.toLocaleString()} Hits</div> 12914 ${creditHtml} 12915 </div> 12916 `; 12917 }).join(''); 12918 12919 // Apply timing durations to all timing elements 12920 carousel.querySelectorAll('.hl-timing-active[data-duration]').forEach(el => { 12921 const duration = parseFloat(el.dataset.duration) || 1; 12922 // Clamp duration between 0.1s and 10s for reasonable animation 12923 const clampedDuration = Math.max(0.1, Math.min(duration, 10)); 12924 el.style.setProperty('--timing-duration', clampedDuration + 's'); 12925 }); 12926 12927 // Setup scrolling for code that overflows 12928 carousel.querySelectorAll('.kidlisp-slide-code').forEach(codeEl => { 12929 const inner = codeEl.querySelector('.kidlisp-slide-code-inner'); 12930 if (!inner) return; 12931 12932 // Wait a tick for layout 12933 requestAnimationFrame(() => { 12934 const containerHeight = codeEl.clientHeight; 12935 const contentHeight = inner.scrollHeight; 12936 12937 if (contentHeight > containerHeight + 20) { 12938 // Content overflows - enable scrolling 12939 const scrollDistance = contentHeight - containerHeight + 20; 12940 inner.classList.add('scrolling'); 12941 inner.style.setProperty('--scroll-distance', `-${scrollDistance}px`); 12942 // Longer scroll duration for more content (base 8s + 0.5s per 100px overflow) 12943 const scrollDuration = 8 + (scrollDistance / 200); 12944 inner.style.setProperty('--scroll-duration', `${scrollDuration}s`); 12945 } 12946 }); 12947 }); 12948 12949 // Helper to update header code display with animation 12950 function updateHeaderCode(code, animate = false) { 12951 const headerCode = document.getElementById('kidlispHeaderCode'); 12952 if (!headerCode) return; 12953 12954 // Build char spans with $ in different green than code 12955 function buildCodeChars(code) { 12956 const dollarSpan = '<span class="code-char code-dollar">$</span>'; 12957 const codeSpans = code.split('').map(c => `<span class="code-char code-name">${c}</span>`).join(''); 12958 return dollarSpan + codeSpans; 12959 } 12960 12961 if (animate) { 12962 // Trigger blast-off on current code 12963 headerCode.classList.add('transitioning'); 12964 setTimeout(() => { 12965 // Update to new code with char spans 12966 headerCode.innerHTML = buildCodeChars(code); 12967 headerCode.classList.remove('transitioning'); 12968 }, 400); 12969 } else { 12970 // Just update without animation 12971 headerCode.innerHTML = buildCodeChars(code); 12972 } 12973 } 12974 12975 // Set initial header link to first slide's code 12976 const firstSlide = carousel.querySelector('.kidlisp-slide'); 12977 if (firstSlide) { 12978 const firstCode = firstSlide.dataset.code; 12979 const headerLink = document.getElementById('kidlispHeaderLink'); 12980 if (headerLink && firstCode) { 12981 headerLink.href = `${kidlispUrl}/$${firstCode}`; 12982 updateHeaderCode(firstCode, false); 12983 } 12984 } 12985 12986 // Advance to next slide (only to ready slides) 12987 function nextSlide() { 12988 const slideEls = carousel.querySelectorAll('.kidlisp-slide'); 12989 if (slideEls.length <= 1) return; 12990 12991 const current = slideEls[currentSlide]; 12992 12993 // Find next ready slide (skip non-ready ones) 12994 let attempts = 0; 12995 let nextIndex = currentSlide; 12996 do { 12997 nextIndex = (nextIndex + 1) % slideEls.length; 12998 attempts++; 12999 } while (!slideEls[nextIndex].classList.contains('ready') && attempts < slideEls.length); 13000 13001 // If no ready slides found, stay on current 13002 if (attempts >= slideEls.length && !slideEls[nextIndex].classList.contains('ready')) { 13003 return; 13004 } 13005 13006 currentSlide = nextIndex; 13007 const next = slideEls[currentSlide]; 13008 13009 // Update header link to current piece 13010 const code = next.dataset.code; 13011 const headerLink = document.getElementById('kidlispHeaderLink'); 13012 if (headerLink && code) { 13013 headerLink.href = `${kidlispUrl}/$${code}`; 13014 updateHeaderCode(code, true); // Animate the header code change 13015 } 13016 13017 // Transition 13018 current.classList.remove('active'); 13019 current.classList.add('exiting'); 13020 next.classList.add('active'); 13021 13022 // Trigger code label blast-off animation on exiting slide 13023 const currentLabel = current.querySelector('.kidlisp-slide-label'); 13024 if (currentLabel) { 13025 currentLabel.classList.add('transitioning'); 13026 setTimeout(() => currentLabel.classList.remove('transitioning'), 500); 13027 } 13028 13029 // Bounce press animation on the whole panel 13030 const previewPanel = carousel.closest('.kidlisp-preview'); 13031 if (previewPanel) { 13032 previewPanel.classList.remove('bounce-press'); 13033 // Force reflow to restart animation 13034 void previewPanel.offsetWidth; 13035 previewPanel.classList.add('bounce-press'); 13036 setTimeout(() => previewPanel.classList.remove('bounce-press'), 400); 13037 } 13038 13039 // Clean up exiting class after transition 13040 setTimeout(() => current.classList.remove('exiting'), 500); 13041 13042 // Restart progress bar 13043 animateKidlispProgress(); 13044 } 13045 13046 // Progress bar animation 13047 const KIDLISP_INTERVAL = 8000; // 8 seconds (synced with other panels) 13048 let kidlispProgressAnim = null; 13049 13050 function animateKidlispProgress() { 13051 const progressBar = document.getElementById('kidlispAutoProgress'); 13052 if (!progressBar) return; 13053 13054 let startTime = null; 13055 13056 function step(timestamp) { 13057 if (!startTime) startTime = timestamp; 13058 const elapsed = timestamp - startTime; 13059 const pct = Math.min((elapsed / KIDLISP_INTERVAL) * 100, 100); 13060 progressBar.style.width = pct + '%'; 13061 13062 if (elapsed < KIDLISP_INTERVAL) { 13063 kidlispProgressAnim = requestAnimationFrame(step); 13064 } 13065 } 13066 13067 if (kidlispProgressAnim) cancelAnimationFrame(kidlispProgressAnim); 13068 progressBar.style.width = '0%'; 13069 kidlispProgressAnim = requestAnimationFrame(step); 13070 } 13071 13072 // Define starter function (called by panelSync when all ready) 13073 function startKidlispRotation() { 13074 // Guard against double-start 13075 if (slideInterval) return; 13076 13077 // Find first ready slide and make it active 13078 const slideEls = carousel.querySelectorAll('.kidlisp-slide'); 13079 let foundReady = false; 13080 for (let i = 0; i < slideEls.length; i++) { 13081 if (slideEls[i].classList.contains('ready')) { 13082 // Clear previous active state 13083 slideEls.forEach(s => s.classList.remove('active')); 13084 slideEls[i].classList.add('active'); 13085 currentSlide = i; 13086 foundReady = true; 13087 13088 // Update header link 13089 const code = slideEls[i].dataset.code; 13090 const headerLink = document.getElementById('kidlispHeaderLink'); 13091 if (headerLink && code) { 13092 headerLink.href = `${kidlispUrl}/$${code}`; 13093 } 13094 break; 13095 } 13096 } 13097 13098 // If no ready slides yet, wait and try again 13099 if (!foundReady) { 13100 setTimeout(startKidlispRotation, 500); 13101 return; 13102 } 13103 13104 animateKidlispProgress(); 13105 slideInterval = setInterval(nextSlide, KIDLISP_INTERVAL); 13106 } 13107 13108 // Mark ready and register starter (don't start yet) 13109 panelSync.markReady('kidlisp'); 13110 panelSync.registerStarter(startKidlispRotation); 13111 13112 // No pause on hover - slides continue advancing 13113 13114 // Track shown sources to detect duplicates 13115 const shownSources = new Map(); // code -> source 13116 const MIN_SOURCE_LENGTH = 50; // Only check sources longer than this 13117 const SIMILARITY_THRESHOLD = 0.9; // 90% similar = duplicate 13118 13119 // Simple similarity check using character trigrams (fast approximation) 13120 function getSourceSimilarity(source1, source2) { 13121 if (!source1 || !source2) return 0; 13122 13123 // Normalize: lowercase, remove extra whitespace 13124 const norm1 = source1.toLowerCase().replace(/\s+/g, ' ').trim(); 13125 const norm2 = source2.toLowerCase().replace(/\s+/g, ' ').trim(); 13126 13127 if (norm1 === norm2) return 1; 13128 if (norm1.length < 10 || norm2.length < 10) return 0; 13129 13130 // Get trigrams (3-char sequences) 13131 function getTrigrams(str) { 13132 const trigrams = new Set(); 13133 for (let i = 0; i <= str.length - 3; i++) { 13134 trigrams.add(str.slice(i, i + 3)); 13135 } 13136 return trigrams; 13137 } 13138 13139 const t1 = getTrigrams(norm1); 13140 const t2 = getTrigrams(norm2); 13141 13142 // Jaccard similarity: intersection / union 13143 let intersection = 0; 13144 for (const t of t1) { 13145 if (t2.has(t)) intersection++; 13146 } 13147 const union = t1.size + t2.size - intersection; 13148 return union > 0 ? intersection / union : 0; 13149 } 13150 13151 // Check if source is too similar to any already shown 13152 function isDuplicateSource(code, source) { 13153 if (!source || source.length < MIN_SOURCE_LENGTH) return false; 13154 13155 for (const [shownCode, shownSource] of shownSources) { 13156 const similarity = getSourceSimilarity(source, shownSource); 13157 if (similarity >= SIMILARITY_THRESHOLD) { 13158 console.warn( 13159 `%c⚠️ DUPLICATE SOURCE: $${code} is ${(similarity * 100).toFixed(0)}% similar to $${shownCode}`, 13160 'color: orange; font-size: 14px;' 13161 ); 13162 return true; 13163 } 13164 } 13165 return false; 13166 } 13167 13168 // Load webp images for all slides (staggered to not overwhelm oven) 13169 const slideEls = carousel.querySelectorAll('.kidlisp-slide'); 13170 for (let i = 0; i < slideEls.length; i++) { 13171 const slide = slideEls[i]; 13172 const code = slide.dataset.code; 13173 // Stagger each request by 300ms 13174 if (i > 0) await new Promise(r => setTimeout(r, 300)); 13175 13176 // Mark as validating (shows loading spinner only) 13177 slide.classList.add('validating'); 13178 13179 const webpUrl = `${OVEN_URL}/grab/webp/256/256/$${code}?duration=6000&fps=10&quality=85&density=1`; 13180 const img = document.createElement('img'); 13181 img.crossOrigin = 'anonymous'; // Enable CORS for pixel validation 13182 // Don't add to DOM yet - validate offscreen first 13183 img.src = webpUrl; 13184 img.alt = code; 13185 13186 img.onload = async () => { 13187 // Only show if image has actual content (not blank/error placeholder) 13188 // Check naturalWidth/Height to ensure it's a real image 13189 if (img.naturalWidth > 1 && img.naturalHeight > 1) { 13190 // Validate pixel content BEFORE adding to DOM (samples 5 frames over ~750ms) 13191 const validation = await validateWebpImage(img, code); 13192 13193 slide.classList.remove('validating'); 13194 13195 if (validation.valid) { 13196 // Check for duplicate source before showing 13197 const source = slide.dataset.source || ''; 13198 if (isDuplicateSource(code, source)) { 13199 // Duplicate detected - slide stays blank 13200 } else { 13201 // Track this source for future duplicate checks 13202 if (source.length >= MIN_SOURCE_LENGTH) { 13203 shownSources.set(code, source); 13204 } 13205 13206 // Only now add to DOM since it's valid and unique 13207 const link = slide.querySelector('a'); 13208 if (link) { 13209 link.innerHTML = ''; 13210 link.appendChild(img); 13211 img.classList.add('loaded'); 13212 // Mark slide as ready - this reveals all content 13213 slide.classList.add('ready'); 13214 } 13215 } 13216 } else { 13217 // Dud detected - slide stays blank (no ready class) 13218 } 13219 } else { 13220 // Blank/tiny image - slide stays blank 13221 slide.classList.remove('validating'); 13222 } 13223 }; 13224 img.onerror = () => { 13225 // On error, slide stays blank 13226 slide.classList.remove('validating'); 13227 }; 13228 13229 // Generate QR code for this slide (don't wait for image) 13230 const qrContainer = slide.querySelector('.kidlisp-slide-qr'); 13231 if (qrContainer && typeof qrcode !== 'undefined') { 13232 try { 13233 const qrUrl = `${kidlispUrl}/$${code}`; 13234 const qrGen = qrcode(0, 'L'); 13235 qrGen.addData(qrUrl); 13236 qrGen.make(); 13237 // Use SVG for better rendering 13238 const svg = qrGen.createSvgTag(2, 0); 13239 qrContainer.innerHTML = svg; 13240 const svgEl = qrContainer.querySelector('svg'); 13241 if (svgEl) { 13242 svgEl.style.display = 'block'; 13243 svgEl.style.width = '48px'; 13244 svgEl.style.height = '48px'; 13245 } 13246 } catch (e) { 13247 console.error('QR generation error:', e); 13248 } 13249 } 13250 } 13251 13252 } catch (e) { 13253 console.error('KidLisp carousel load error:', e); 13254 if (SERVER_SUSPENDED) { 13255 renderSuspended(carousel, 'card'); 13256 } else { 13257 const loadErrorTexts = { en: 'Could not load', da: 'Kunne ikke indlæse', de: 'Konnte nicht laden', es: 'No se pudo cargar', zh: '无法加载' }; 13258 carousel.innerHTML = `<div style="text-align: center; color: #555; padding: 2em;">${loadErrorTexts[currentLang] || loadErrorTexts.en}</div>`; 13259 } 13260 panelSync.markReady('kidlisp'); // Still mark ready on error 13261 } 13262 } 13263 13264 setTimeout(loadKidlispCarousel, 800); 13265 13266 // Desktop Release Info - Fetch from GitHub 13267 async function loadDesktopReleaseInfo() { 13268 try { 13269 const res = await fetch('https://api.github.com/repos/whistlegraph/aesthetic-computer/releases/latest'); 13270 if (!res.ok) return; 13271 13272 const release = await res.json(); 13273 const version = release.tag_name; 13274 13275 // Update all platform version badges 13276 const macVer = document.getElementById('macVersion'); 13277 const winVer = document.getElementById('winVersion'); 13278 const linuxVer = document.getElementById('linuxVersion'); 13279 if (macVer) macVer.textContent = version; 13280 if (winVer) winVer.textContent = version; 13281 if (linuxVer) linuxVer.textContent = version; 13282 13283 // Update download links with the latest version 13284 document.querySelectorAll('.desktop-platform-btn').forEach(btn => { 13285 const href = btn.getAttribute('href'); 13286 if (href && href.includes('v0.1.8')) { 13287 btn.setAttribute('href', href.replace(/v[\d.]+/g, version).replace(/[\d.]+(?=[-.](?:universal|Setup|AppImage))/g, version.replace('v', ''))); 13288 } 13289 }); 13290 13291 // Build changelog ticker content 13292 const tickerEl = document.getElementById('changelogTicker'); 13293 if (tickerEl) { 13294 if (release.body) { 13295 const body = release.body; 13296 const items = []; 13297 13298 // Extract list items from markdown 13299 const lines = body.split('\n'); 13300 for (const line of lines) { 13301 const boldMatch = line.match(/^[\-\*]\s*\*\*(.+?)\*\*\s*[-–]?\s*(.*)$/); 13302 const simpleMatch = line.match(/^[\-\*]\s+(.+)$/); 13303 13304 if (boldMatch) { 13305 const [, bold, rest] = boldMatch; 13306 items.push(`${bold}${rest ? ': ' + rest : ''}`); 13307 } else if (simpleMatch && !simpleMatch[1].startsWith('**')) { 13308 items.push(simpleMatch[1]); 13309 } 13310 } 13311 13312 if (items.length > 0) { 13313 // Duplicate for seamless loop 13314 const tickerText = items.map(i => `<span>${i}</span>`).join(''); 13315 tickerEl.innerHTML = tickerText + tickerText; 13316 } else { 13317 tickerEl.innerHTML = `<span>${release.name || version} released</span><span>${release.name || version} released</span>`; 13318 } 13319 } else { 13320 // No release notes - show version info with release date 13321 const dateStr = release.published_at ? new Date(release.published_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''; 13322 const msg = `${version} released${dateStr ? ' · ' + dateStr : ''}`; 13323 tickerEl.innerHTML = `<span>${msg}</span><span>${msg}</span>`; 13324 } 13325 } 13326 13327 } catch (e) { 13328 console.warn('Could not load release info:', e); 13329 const tickerEl = document.getElementById('changelogTicker'); 13330 if (tickerEl) tickerEl.innerHTML = '<span>Visit GitHub for release notes</span><span>Visit GitHub for release notes</span>'; 13331 } 13332 } 13333 13334 // Fetch VS Code extension version from marketplace 13335 async function loadVSCodeExtVersion() { 13336 try { 13337 // Use the VS Code Marketplace API 13338 const res = await fetch('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', { 13339 method: 'POST', 13340 headers: { 13341 'Content-Type': 'application/json', 13342 'Accept': 'application/json;api-version=6.0-preview.1' 13343 }, 13344 body: JSON.stringify({ 13345 filters: [{ 13346 criteria: [{ filterType: 7, value: 'aesthetic-computer.aesthetic-computer-code' }] 13347 }], 13348 flags: 0x200 // Include versions 13349 }) 13350 }); 13351 13352 if (res.ok) { 13353 const data = await res.json(); 13354 const ext = data.results?.[0]?.extensions?.[0]; 13355 if (ext?.versions?.[0]?.version) { 13356 const versionEl = document.getElementById('vscodeExtVersion'); 13357 if (versionEl) versionEl.textContent = 'v' + ext.versions[0].version; 13358 } 13359 } 13360 } catch (e) { 13361 console.warn('Could not load VS Code extension version:', e); 13362 } 13363 } 13364 13365 setTimeout(loadDesktopReleaseInfo, 900); 13366 setTimeout(loadVSCodeExtVersion, 1000); 13367 13368 // Apps Flip Card - Desktop / Mobile swivel 13369 function toggleAppsFlip(direction) { 13370 const card = document.getElementById('appsFlipCard'); 13371 const container = document.getElementById('appsFlipContainer'); 13372 if (card && container) { 13373 const isFlipped = card.classList.contains('flipped'); 13374 // If direction specified, only flip if it makes sense 13375 if (direction === 'left' && isFlipped) { 13376 card.classList.remove('flipped'); 13377 container.classList.remove('flipped'); 13378 resetAppsFlipProgress(); 13379 } else if (direction === 'right' && !isFlipped) { 13380 card.classList.add('flipped'); 13381 container.classList.add('flipped'); 13382 resetAppsFlipProgress(); 13383 } else if (!direction) { 13384 card.classList.toggle('flipped'); 13385 container.classList.toggle('flipped'); 13386 resetAppsFlipProgress(); 13387 } 13388 } 13389 } 13390 window.toggleAppsFlip = toggleAppsFlip; 13391 13392 // Apps flip hover tilt effect 13393 (function setupAppsHoverTilt() { 13394 const container = document.getElementById('appsFlipContainer'); 13395 const card = document.getElementById('appsFlipCard'); 13396 if (!container || !card) return; 13397 13398 container.addEventListener('mousemove', (e) => { 13399 if (card.classList.contains('flipped')) return; // Don't tilt when flipped 13400 const rect = container.getBoundingClientRect(); 13401 const x = e.clientX - rect.left; 13402 const width = rect.width; 13403 const third = width / 3; 13404 13405 card.classList.remove('tilt-left', 'tilt-right'); 13406 if (x < third) { 13407 card.classList.add('tilt-left'); 13408 } else if (x > third * 2) { 13409 card.classList.add('tilt-right'); 13410 } 13411 }); 13412 13413 container.addEventListener('mouseleave', () => { 13414 card.classList.remove('tilt-left', 'tilt-right'); 13415 }); 13416 13417 // Click zones for directional swivel 13418 container.addEventListener('click', (e) => { 13419 // Don't trigger if clicking on a link 13420 if (e.target.closest('a')) return; 13421 13422 const rect = container.getBoundingClientRect(); 13423 const x = e.clientX - rect.left; 13424 const width = rect.width; 13425 const isFlipped = card.classList.contains('flipped'); 13426 13427 if (x < width / 2) { 13428 // Left side - swivel to show front (unflip) 13429 if (isFlipped) toggleAppsFlip('left'); 13430 } else { 13431 // Right side - swivel to show back (flip) 13432 if (!isFlipped) toggleAppsFlip('right'); 13433 } 13434 }); 13435 })(); 13436 13437 // Apps flip auto-flip with progress bar 13438 const APPS_FLIP_INTERVAL = 8000; // 8 seconds between flips (synced with other panels) 13439 let appsFlipProgress = 0; 13440 13441 function resetAppsFlipProgress() { 13442 appsFlipProgress = 0; 13443 const bar = document.getElementById('appsFlipProgress'); 13444 const barMobile = document.getElementById('appsFlipProgressMobile'); 13445 if (bar) bar.style.width = '0%'; 13446 if (barMobile) barMobile.style.width = '0%'; 13447 } 13448 13449 function updateAppsFlipProgress() { 13450 const bar = document.getElementById('appsFlipProgress'); 13451 const barMobile = document.getElementById('appsFlipProgressMobile'); 13452 const card = document.getElementById('appsFlipCard'); 13453 const isFlipped = card && card.classList.contains('flipped'); 13454 13455 appsFlipProgress += 100 / (APPS_FLIP_INTERVAL / 100); // Update every 100ms 13456 // Only update the visible face's progress bar 13457 if (isFlipped) { 13458 if (barMobile) barMobile.style.width = appsFlipProgress + '%'; 13459 if (bar) bar.style.width = '0%'; 13460 } else { 13461 if (bar) bar.style.width = appsFlipProgress + '%'; 13462 if (barMobile) barMobile.style.width = '0%'; 13463 } 13464 13465 if (appsFlipProgress >= 100) { 13466 toggleAppsFlip(); 13467 } 13468 13469 setTimeout(updateAppsFlipProgress, 100); 13470 } 13471 13472 // Mark apps panel ready immediately (static content, no async load) 13473 panelSync.markReady('apps'); 13474 // Register apps flip starter with sync controller 13475 panelSync.registerStarter(() => updateAppsFlipProgress()); 13476 13477 // 📊 Stats Section Visibility Manager 13478 // Pauses all floating stat spawners when stats section is not visible 13479 const statsVisibility = { 13480 isVisible: true, 13481 spawners: [], // { id, spawnFn, intervalMs, intervalId } 13482 register(spawnFn, intervalMs) { 13483 const spawner = { id: this.spawners.length, spawnFn, intervalMs, intervalId: null }; 13484 this.spawners.push(spawner); 13485 if (this.isVisible) { 13486 spawner.intervalId = setInterval(spawnFn, intervalMs); 13487 } 13488 return spawner.id; 13489 }, 13490 start() { 13491 if (this.isVisible) return; 13492 this.isVisible = true; 13493 for (const s of this.spawners) { 13494 if (!s.intervalId) { 13495 s.intervalId = setInterval(s.spawnFn, s.intervalMs); 13496 } 13497 } 13498 }, 13499 stop() { 13500 if (!this.isVisible) return; 13501 this.isVisible = false; 13502 for (const s of this.spawners) { 13503 if (s.intervalId) { 13504 clearInterval(s.intervalId); 13505 s.intervalId = null; 13506 } 13507 } 13508 } 13509 }; 13510 13511 // Set up IntersectionObserver for stats section 13512 const statsSection = document.querySelector('.stats-section'); 13513 if (statsSection) { 13514 const statsObserver = new IntersectionObserver((entries) => { 13515 for (const entry of entries) { 13516 if (entry.isIntersecting) { 13517 statsVisibility.start(); 13518 } else { 13519 statsVisibility.stop(); 13520 } 13521 } 13522 }, { threshold: 0 }); 13523 statsObserver.observe(statsSection); 13524 } 13525 13526 // 🎨 Floating Paintings in Stats 13527 async function loadFloatingPaintings() { 13528 const statItem = document.getElementById('stat-paintings'); 13529 if (!statItem) return; 13530 13531 const floatersContainer = statItem.querySelector('.stat-floaters'); 13532 if (!floatersContainer) return; 13533 13534 try { 13535 // Fetch paintings from TV endpoint 13536 const res = await fetch(`${apiBase}/api/tv?types=painting&limit=50`); 13537 if (!res.ok) throw new Error('Failed to fetch paintings'); 13538 13539 const data = await res.json(); 13540 const paintings = data.media?.paintings || []; 13541 if (!paintings.length) { 13542 return; 13543 } 13544 13545 // Group paintings by handle and take max 3-4 per user for variety 13546 const byHandle = {}; 13547 for (const p of paintings) { 13548 if (!p.media?.url) continue; 13549 if (!p.owner?.handle) continue; 13550 const h = p.owner.handle; 13551 if (!byHandle[h]) byHandle[h] = []; 13552 if (byHandle[h].length < 4) byHandle[h].push(p); 13553 } 13554 13555 // Interleave paintings from different handles 13556 const mixed = []; 13557 const handles = Object.keys(byHandle); 13558 let moreToAdd = true; 13559 let idx = 0; 13560 while (moreToAdd) { 13561 moreToAdd = false; 13562 for (const h of handles) { 13563 if (byHandle[h][idx]) { 13564 mixed.push(byHandle[h][idx]); 13565 moreToAdd = true; 13566 } 13567 } 13568 idx++; 13569 } 13570 13571 // Don't preload all - lazy load as needed 13572 let index = 0; 13573 13574 function spawnFloater() { 13575 if (!mixed.length) return; 13576 13577 const p = mixed[index % mixed.length]; 13578 index++; 13579 13580 const floater = document.createElement('div'); 13581 floater.className = 'stat-floater'; 13582 13583 // Random pixel size: 2x (32px), 3x (48px), or 4x (64px) 13584 const sizes = [32, 48, 64]; 13585 const size = sizes[Math.floor(Math.random() * sizes.length)]; 13586 13587 // Random horizontal position (5% to 95%) 13588 const xPos = 5 + Math.random() * 90; 13589 floater.style.left = `calc(${xPos}% - ${size/2}px)`; 13590 floater.style.bottom = '-20px'; 13591 13592 // Random duration between 10-20s (independent of size) 13593 const duration = 10 + Math.random() * 10; 13594 floater.style.setProperty('--float-duration', `${duration}s`); 13595 13596 // Thumbnail - lazy load this image 13597 const thumb = document.createElement('img'); 13598 thumb.src = p.media.url; 13599 thumb.alt = p.code || 'painting'; 13600 thumb.style.width = `${size}px`; 13601 thumb.style.height = `${size}px`; 13602 thumb.loading = 'lazy'; 13603 floater.appendChild(thumb); 13604 13605 // Code label 13606 if (p.code) { 13607 const code = document.createElement('span'); 13608 code.className = 'floater-code'; 13609 code.style.fontSize = '0.8em'; 13610 code.style.color = 'var(--gold)'; 13611 code.textContent = `#${p.code}`; 13612 floater.appendChild(code); 13613 } 13614 13615 floatersContainer.appendChild(floater); 13616 13617 // Remove after animation completes 13618 floater.addEventListener('animationend', () => floater.remove()); 13619 } 13620 13621 // Spawn one immediately, then register with visibility manager 13622 spawnFloater(); 13623 statsVisibility.register(spawnFloater, 3000); 13624 13625 } catch (e) { 13626 console.warn('Could not load floating paintings:', e); 13627 } 13628 } 13629 13630 setTimeout(loadFloatingPaintings, 2000); 13631 13632 // 🏷️ Floating Handles in Stats 13633 async function loadFloatingHandles() { 13634 const statItem = document.getElementById('stat-handles'); 13635 if (!statItem) return; 13636 13637 const floatersContainer = statItem.querySelector('.stat-floaters'); 13638 if (!floatersContainer) return; 13639 13640 try { 13641 // Get handles from moods API (each mood has a handle) 13642 const res = await fetch(`${apiBase}/api/mood/all`); 13643 if (!res.ok) throw new Error('Failed to fetch moods for handles'); 13644 13645 const data = await res.json(); 13646 const moods = data.moods || []; 13647 13648 // Extract unique handles 13649 const handleSet = new Set(); 13650 for (const m of moods) { 13651 if (m.handle) handleSet.add(m.handle); 13652 } 13653 const handles = Array.from(handleSet); 13654 13655 if (!handles.length) return; 13656 13657 let index = 0; 13658 13659 function spawnFloater() { 13660 const handle = handles[index % handles.length]; 13661 index++; 13662 13663 const floater = document.createElement('div'); 13664 13665 // Randomly choose left or right direction 13666 const goRight = Math.random() > 0.5; 13667 floater.className = goRight ? 'stat-floater float-horizontal-right' : 'stat-floater float-horizontal'; 13668 13669 // Random vertical position (10% to 90%) 13670 const yPos = 10 + Math.random() * 80; 13671 floater.style.top = `${yPos}%`; 13672 if (goRight) { 13673 floater.style.left = '-80px'; 13674 } else { 13675 floater.style.right = '-50px'; 13676 } 13677 13678 // Random duration between 8-14s 13679 const duration = 8 + Math.random() * 6; 13680 floater.style.setProperty('--float-duration', `${duration}s`); 13681 13682 // Handle text 13683 const text = document.createElement('span'); 13684 text.className = 'floater-code'; 13685 text.style.fontSize = '0.9em'; 13686 text.style.color = 'var(--cyan)'; 13687 text.textContent = handle; 13688 floater.appendChild(text); 13689 13690 floatersContainer.appendChild(floater); 13691 floater.addEventListener('animationend', () => floater.remove()); 13692 } 13693 13694 spawnFloater(); 13695 statsVisibility.register(spawnFloater, 4000); 13696 13697 } catch (e) { 13698 console.warn('Could not load floating handles:', e); 13699 } 13700 } 13701 13702 setTimeout(loadFloatingHandles, 2500); 13703 13704 // 😊 Floating Moods in Stats 13705 async function loadFloatingMoods() { 13706 const statItem = document.getElementById('stat-moods'); 13707 if (!statItem) return; 13708 13709 const floatersContainer = statItem.querySelector('.stat-floaters'); 13710 if (!floatersContainer) return; 13711 13712 try { 13713 const res = await fetch(`${apiBase}/api/mood/all`); 13714 if (!res.ok) throw new Error('Failed to fetch moods'); 13715 13716 const data = await res.json(); 13717 const moods = data.moods || []; 13718 13719 // Group by handle, max 3 per user for variety 13720 const byHandle = {}; 13721 for (const m of moods) { 13722 if (!m.mood || !m.handle) continue; 13723 if (!byHandle[m.handle]) byHandle[m.handle] = []; 13724 if (byHandle[m.handle].length < 3) byHandle[m.handle].push(m); 13725 } 13726 13727 // Interleave moods from different handles 13728 const mixed = []; 13729 const handles = Object.keys(byHandle); 13730 let moreToAdd = true; 13731 let idx = 0; 13732 while (moreToAdd) { 13733 moreToAdd = false; 13734 for (const h of handles) { 13735 if (byHandle[h][idx]) { 13736 mixed.push(byHandle[h][idx]); 13737 moreToAdd = true; 13738 } 13739 } 13740 idx++; 13741 } 13742 13743 if (!mixed.length) return; 13744 13745 let index = 0; 13746 13747 function spawnFloater() { 13748 const m = mixed[index % mixed.length]; 13749 index++; 13750 13751 const floater = document.createElement('div'); 13752 13753 // Randomly choose left or right direction 13754 const goRight = Math.random() > 0.5; 13755 floater.className = goRight ? 'stat-floater float-horizontal-right' : 'stat-floater float-horizontal'; 13756 13757 // Random vertical position (10% to 90%) 13758 const yPos = 10 + Math.random() * 80; 13759 floater.style.top = `${yPos}%`; 13760 if (goRight) { 13761 floater.style.left = '-80px'; 13762 } else { 13763 floater.style.right = '-50px'; 13764 } 13765 13766 // Random duration between 10-18s (moods float slower) 13767 const duration = 10 + Math.random() * 8; 13768 floater.style.setProperty('--float-duration', `${duration}s`); 13769 13770 // Mood text (truncate if too long) 13771 const text = document.createElement('span'); 13772 text.className = 'floater-code'; 13773 text.style.fontSize = '0.7em'; 13774 text.style.maxWidth = '80px'; 13775 text.style.overflow = 'hidden'; 13776 text.style.textOverflow = 'ellipsis'; 13777 text.style.color = 'var(--gold)'; 13778 const moodText = m.mood.length > 20 ? m.mood.slice(0, 18) + '…' : m.mood; 13779 text.textContent = moodText; 13780 floater.appendChild(text); 13781 13782 floatersContainer.appendChild(floater); 13783 floater.addEventListener('animationend', () => floater.remove()); 13784 } 13785 13786 spawnFloater(); 13787 statsVisibility.register(spawnFloater, 3500); 13788 13789 } catch (e) { 13790 console.warn('Could not load floating moods:', e); 13791 } 13792 } 13793 13794 setTimeout(loadFloatingMoods, 3000); 13795 13796 // 💻 Floating KidLisp Codes in Stats 13797 async function loadFloatingKidlisp() { 13798 const statItem = document.getElementById('stat-kidlisp'); 13799 if (!statItem) return; 13800 13801 const floatersContainer = statItem.querySelector('.stat-floaters'); 13802 if (!floatersContainer) return; 13803 13804 try { 13805 const res = await fetch(`${apiBase}/api/tv?types=kidlisp&limit=100`); 13806 if (!res.ok) throw new Error('Failed to fetch kidlisp'); 13807 13808 const data = await res.json(); 13809 const kidlisps = data.media?.kidlisp || []; 13810 13811 // Group by handle, max 4 per user for variety 13812 const byHandle = {}; 13813 for (const k of kidlisps) { 13814 if (!k.code) continue; 13815 const h = k.owner?.handle || 'anonymous'; 13816 if (!byHandle[h]) byHandle[h] = []; 13817 if (byHandle[h].length < 4) byHandle[h].push(k); 13818 } 13819 13820 // Interleave from different handles 13821 const mixed = []; 13822 const handles = Object.keys(byHandle); 13823 let moreToAdd = true; 13824 let idx = 0; 13825 while (moreToAdd) { 13826 moreToAdd = false; 13827 for (const h of handles) { 13828 if (byHandle[h][idx]) { 13829 mixed.push(byHandle[h][idx]); 13830 moreToAdd = true; 13831 } 13832 } 13833 idx++; 13834 } 13835 13836 if (!mixed.length) return; 13837 13838 let index = 0; 13839 13840 function spawnFloater() { 13841 const k = mixed[index % mixed.length]; 13842 index++; 13843 13844 const floater = document.createElement('div'); 13845 floater.className = 'stat-floater'; 13846 13847 // Random horizontal position (5% to 95%) 13848 const xPos = 5 + Math.random() * 90; 13849 floater.style.left = `${xPos}%`; 13850 floater.style.bottom = '-20px'; 13851 13852 // Random duration between 10-18s 13853 const duration = 10 + Math.random() * 8; 13854 floater.style.setProperty('--float-duration', `${duration}s`); 13855 13856 // Code text 13857 const text = document.createElement('span'); 13858 text.className = 'floater-code'; 13859 text.style.fontSize = '0.8em'; 13860 text.style.color = 'var(--magenta)'; 13861 text.textContent = `$${k.code}`; 13862 floater.appendChild(text); 13863 13864 floatersContainer.appendChild(floater); 13865 floater.addEventListener('animationend', () => floater.remove()); 13866 } 13867 13868 spawnFloater(); 13869 statsVisibility.register(spawnFloater, 3000); 13870 13871 } catch (e) { 13872 console.warn('Could not load floating kidlisp:', e); 13873 } 13874 } 13875 13876 setTimeout(loadFloatingKidlisp, 3500); 13877 13878 // 🧩 Floating Commands (pieces + prompts) in Stats 13879 async function loadFloatingCommands() { 13880 const statItem = document.getElementById('stat-commands'); 13881 if (!statItem) return; 13882 13883 const floatersContainer = statItem.querySelector('.stat-floaters'); 13884 if (!floatersContainer) return; 13885 13886 try { 13887 // Fetch pieces from API 13888 const res = await fetch(`${apiBase}/api/piece-hit?top=50`); 13889 if (!res.ok) throw new Error('Failed to fetch pieces'); 13890 13891 const data = await res.json(); 13892 const pieces = (data.pieces || []).filter(p => { 13893 return p.piece && !p.piece.startsWith('api/') && !p.piece.includes('.'); 13894 }).map(p => ({ name: p.piece, type: 'piece' })); 13895 13896 // Hard-coded prompts (commands you type in prompt) 13897 const prompts = [ 13898 'tape', 'cut', 'keep', 'mint', 'print', 'scream', 'me', 'selfie', 13899 'cam', 'flower', 'petal', 'sparkle', 'done', 'tezos', 'notifs', 13900 'bro', 'sis', 'bf', 'gf', 'email', 'handle', 'logout', 'leave', 13901 'join', 'mood', 'profile', 'code', 'upload', 'download', 'delete' 13902 ].map(name => ({ name, type: 'prompt' })); 13903 13904 // Interleave pieces and prompts 13905 const commands = []; 13906 const maxLen = Math.max(pieces.length, prompts.length); 13907 for (let i = 0; i < maxLen; i++) { 13908 if (pieces[i]) commands.push(pieces[i]); 13909 if (prompts[i]) commands.push(prompts[i]); 13910 } 13911 13912 if (!commands.length) return; 13913 13914 let index = 0; 13915 13916 function spawnFloater() { 13917 const cmd = commands[index % commands.length]; 13918 index++; 13919 13920 const floater = document.createElement('div'); 13921 floater.className = 'stat-floater'; 13922 13923 // Random horizontal position (5% to 95%) 13924 const xPos = 5 + Math.random() * 90; 13925 floater.style.left = `${xPos}%`; 13926 floater.style.bottom = '-20px'; 13927 13928 // Random duration between 10-18s 13929 const duration = 10 + Math.random() * 8; 13930 floater.style.setProperty('--float-duration', `${duration}s`); 13931 13932 // Command name - color coded 13933 const text = document.createElement('span'); 13934 text.className = 'floater-code'; 13935 text.style.fontSize = '0.7em'; 13936 // Green for pieces (apps), Yellow/gold for prompts (commands) 13937 text.style.color = cmd.type === 'piece' ? 'var(--green)' : 'var(--gold)'; 13938 text.textContent = cmd.name; 13939 floater.appendChild(text); 13940 13941 floatersContainer.appendChild(floater); 13942 floater.addEventListener('animationend', () => floater.remove()); 13943 } 13944 13945 spawnFloater(); 13946 statsVisibility.register(spawnFloater, 2500); 13947 13948 } catch (e) { 13949 console.warn('Could not load floating commands:', e); 13950 } 13951 } 13952 13953 setTimeout(loadFloatingCommands, 4000); 13954 13955 // 💬 Floating Messages in Stats 13956 async function loadFloatingMessages() { 13957 const statItem = document.getElementById('stat-messages'); 13958 if (!statItem) return; 13959 13960 const floatersContainer = statItem.querySelector('.stat-floaters'); 13961 if (!floatersContainer) return; 13962 13963 try { 13964 // Fetch recent messages from both laer-klokken and main chat 13965 const [clockRes, chatRes] = await Promise.all([ 13966 fetch(`${apiBase}/api/chat/messages?instance=clock&limit=30`), 13967 fetch(`${apiBase}/api/chat/messages?limit=30`) 13968 ]); 13969 13970 const clockData = await clockRes.json(); 13971 const chatData = await chatRes.json(); 13972 13973 const clockMsgs = (clockData.messages || []).map(m => ({ ...m, source: 'clock' })); 13974 const chatMsgs = (chatData.messages || []).map(m => ({ ...m, source: 'chat' })); 13975 13976 // Interleave messages from both sources 13977 const allMsgs = []; 13978 const maxLen = Math.max(clockMsgs.length, chatMsgs.length); 13979 for (let i = 0; i < maxLen; i++) { 13980 if (clockMsgs[i]) allMsgs.push(clockMsgs[i]); 13981 if (chatMsgs[i]) allMsgs.push(chatMsgs[i]); 13982 } 13983 13984 if (!allMsgs.length) return; 13985 13986 let index = 0; 13987 13988 function spawnFloater() { 13989 const msg = allMsgs[index % allMsgs.length]; 13990 index++; 13991 13992 const floater = document.createElement('div'); 13993 floater.className = 'stat-floater float-horizontal'; 13994 13995 // Random vertical position (10% to 90%) 13996 const yPos = 10 + Math.random() * 80; 13997 floater.style.top = `${yPos}%`; 13998 floater.style.right = '-80px'; 13999 14000 // Random duration between 8-14s 14001 const duration = 8 + Math.random() * 6; 14002 floater.style.setProperty('--float-duration', `${duration}s`); 14003 14004 // Message text (truncated) 14005 const text = document.createElement('span'); 14006 text.className = 'floater-code'; 14007 text.style.fontSize = '0.65em'; 14008 text.style.maxWidth = '100px'; 14009 text.style.overflow = 'hidden'; 14010 text.style.textOverflow = 'ellipsis'; 14011 // Yellow for clock messages, cyan for chat 14012 text.style.color = msg.source === 'clock' ? 'var(--gold)' : 'var(--cyan)'; 14013 14014 // Truncate message 14015 const msgText = (msg.text || '').slice(0, 25); 14016 const handle = msg.from || 'anon'; 14017 text.textContent = `${handle}: ${msgText}${msg.text?.length > 25 ? '…' : ''}`; 14018 floater.appendChild(text); 14019 14020 floatersContainer.appendChild(floater); 14021 floater.addEventListener('animationend', () => floater.remove()); 14022 } 14023 14024 spawnFloater(); 14025 statsVisibility.register(spawnFloater, 2000); 14026 14027 } catch (e) { 14028 console.warn('Could not load floating messages:', e); 14029 } 14030 } 14031 14032 setTimeout(loadFloatingMessages, 4500); 14033 14034 // ====== Auth0 Authentication ====== 14035 let auth0Client = null; 14036 let acUser = null; 14037 let acHandle = null; 14038 14039 async function initAuth0() { 14040 try { 14041 auth0Client = await auth0.createAuth0Client({ 14042 domain: 'aesthetic.us.auth0.com', 14043 clientId: 'LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt', 14044 authorizationParams: { 14045 redirect_uri: window.location.origin + window.location.pathname, 14046 audience: 'https://aesthetic.us.auth0.com/api/v2/' 14047 }, 14048 cacheLocation: 'localstorage', 14049 useRefreshTokens: true, 14050 useRefreshTokensFallback: true 14051 }); 14052 14053 // Handle redirect callback 14054 if (window.location.search.includes('code=') || window.location.search.includes('error=')) { 14055 await auth0Client.handleRedirectCallback(); 14056 window.history.replaceState({}, document.title, window.location.pathname); 14057 } 14058 14059 // Check if logged in locally first 14060 let isAuthenticated = await auth0Client.isAuthenticated(); 14061 14062 // If not logged in locally, try silent auth to check for existing session from aesthetic.computer 14063 if (!isAuthenticated) { 14064 try { 14065 await auth0Client.getTokenSilently(); 14066 isAuthenticated = await auth0Client.isAuthenticated(); 14067 } catch (e) { 14068 // Silent auth failed - user not logged in on aesthetic.computer either 14069 } 14070 } 14071 14072 if (isAuthenticated) { 14073 acUser = await auth0Client.getUser(); 14074 // Fetch handle from AC API using email 14075 try { 14076 const res = await fetch( 14077 `https://aesthetic.computer/user?from=${encodeURIComponent(acUser.email)}&withHandle=true` 14078 ); 14079 if (res.ok) { 14080 const data = await res.json(); 14081 if (data.handle) { 14082 acHandle = data.handle; 14083 } 14084 } 14085 } catch (e) { 14086 console.warn('Could not fetch handle:', e); 14087 } 14088 } 14089 14090 updateFooterAuthUI(); 14091 } catch (e) { 14092 console.error('Auth0 init error:', e); 14093 updateFooterAuthUI(); 14094 } 14095 } 14096 14097 function updateFooterAuthUI() { 14098 const authButtons = document.getElementById('footerAuthButtons'); 14099 const userStack = document.getElementById('footerUserStack'); 14100 const userMenu = document.getElementById('footerUserMenu'); 14101 const userHandle = document.getElementById('footerUserHandle'); 14102 14103 if (acUser) { 14104 // Logged in - show handle + logout stack 14105 if (authButtons) authButtons.style.display = 'none'; 14106 if (userStack) userStack.style.display = 'flex'; 14107 if (userMenu) { 14108 const displayName = acHandle ? `@${acHandle}` : acUser.email?.split('@')[0] || 'User'; 14109 if (userHandle) userHandle.textContent = displayName; 14110 // Link to profile if we have a handle 14111 if (acHandle) { 14112 userMenu.href = `https://aesthetic.computer/@${acHandle}`; 14113 } else { 14114 userMenu.href = 'https://aesthetic.computer/profile'; 14115 } 14116 } 14117 } else { 14118 // Logged out - show auth buttons 14119 if (authButtons) authButtons.style.display = 'flex'; 14120 if (userStack) userStack.style.display = 'none'; 14121 } 14122 } 14123 14124 async function footerLogin() { 14125 if (!auth0Client) await initAuth0(); 14126 if (!auth0Client) return; 14127 await auth0Client.loginWithRedirect(); 14128 } 14129 14130 async function footerSignup() { 14131 if (!auth0Client) await initAuth0(); 14132 if (!auth0Client) return; 14133 await auth0Client.loginWithRedirect({ 14134 authorizationParams: { screen_hint: 'signup' } 14135 }); 14136 } 14137 14138 async function getAuthToken() { 14139 if (!auth0Client) await initAuth0(); 14140 if (!auth0Client) return null; 14141 try { 14142 return await auth0Client.getTokenSilently(); 14143 } catch { 14144 return null; 14145 } 14146 } 14147 14148 // Footer auth button event listeners 14149 document.getElementById('footerLoginBtn')?.addEventListener('click', footerLogin); 14150 document.getElementById('footerSignupBtn')?.addEventListener('click', footerSignup); 14151 document.getElementById('footerLogoutLink')?.addEventListener('click', async (e) => { 14152 e.preventDefault(); 14153 if (!auth0Client) return; 14154 await auth0Client.logout({ 14155 logoutParams: { returnTo: window.location.origin + window.location.pathname } 14156 }); 14157 }); 14158 14159 // Initialize auth 14160 initAuth0(); 14161 </script> 14162 14163 <!-- 🔄 Development live reload via session-server --> 14164 <script> 14165 (function() { 14166 // Only run in development (localhost) 14167 if (window.location.hostname !== 'localhost' && !window.location.hostname.includes('local.')) return; 14168 14169 let sessionWs = null; 14170 let reconnectInterval = null; 14171 14172 function connect() { 14173 if (sessionWs && sessionWs.readyState === WebSocket.OPEN) return; 14174 if (reconnectInterval) { 14175 clearInterval(reconnectInterval); 14176 reconnectInterval = null; 14177 } 14178 14179 const connectionUrl = window.location.hostname.includes('local.') 14180 ? 'wss://session.local.aesthetic.computer' 14181 : 'wss://localhost:8889'; 14182 14183 try { 14184 sessionWs = new WebSocket(connectionUrl); 14185 } catch (error) { 14186 scheduleReconnect(); 14187 return; 14188 } 14189 14190 sessionWs.onopen = () => { 14191 }; 14192 14193 sessionWs.onmessage = (e) => { 14194 try { 14195 const msg = JSON.parse(e.data); 14196 if (msg.type === 'reload') { 14197 const piece = msg.content?.piece; 14198 if (piece === '*refresh*' || piece === 'give.aesthetic.computer' || piece?.includes('give')) { 14199 setTimeout(() => window.location.reload(), 150); 14200 } 14201 } 14202 } catch (error) { 14203 // Ignore parse errors 14204 } 14205 }; 14206 14207 sessionWs.onerror = () => { 14208 }; 14209 14210 sessionWs.onclose = () => { 14211 sessionWs = null; 14212 scheduleReconnect(); 14213 }; 14214 } 14215 14216 function scheduleReconnect() { 14217 if (!reconnectInterval) { 14218 reconnectInterval = setInterval(connect, 3000); 14219 } 14220 } 14221 14222 // Connect on load 14223 connect(); 14224 })(); 14225 </script> 14226 14227 <script> 14228 // Fetch AT Protocol record counts 14229 (async function loadATStats() { 14230 const PDS = 'https://at.aesthetic.computer'; 14231 14232 async function countRecords(collection) { 14233 try { 14234 // Use describeRepo to get rough stats, or listRecords with limit 14235 const res = await fetch(`${PDS}/xrpc/com.atproto.sync.listRepos?limit=1000`); 14236 if (!res.ok) return '?'; 14237 const data = await res.json(); 14238 14239 // Count records across all repos for this collection 14240 let total = 0; 14241 for (const repo of data.repos || []) { 14242 try { 14243 const recRes = await fetch(`${PDS}/xrpc/com.atproto.repo.listRecords?repo=${repo.did}&collection=${collection}&limit=100`); 14244 if (recRes.ok) { 14245 const recData = await recRes.json(); 14246 total += (recData.records || []).length; 14247 } 14248 } catch (e) {} 14249 } 14250 return total; 14251 } catch (e) { 14252 console.error('[AT Stats]', e); 14253 return '?'; 14254 } 14255 } 14256 14257 // Simpler approach: just hit the TV API which already aggregates tapes 14258 try { 14259 const tvRes = await fetch('https://aesthetic.computer/api/tv'); 14260 if (tvRes.ok) { 14261 const tvData = await tvRes.json(); 14262 const tapesEl = document.getElementById('atTapesCount'); 14263 if (tapesEl && tvData.tapes) tapesEl.textContent = tvData.tapes.length; 14264 } 14265 } catch (e) {} 14266 14267 // For paintings/handles, show placeholder for now 14268 const paintingsEl = document.getElementById('atPaintingsCount'); 14269 const handlesEl = document.getElementById('atHandlesCount'); 14270 if (paintingsEl) paintingsEl.textContent = '~1k'; 14271 if (handlesEl) handlesEl.textContent = '~100'; 14272 })(); 14273 </script> 14274 14275 <script> 14276 // AT Protocol handle cycling animation 14277 (async function initATHandleCycler() { 14278 const prefixEl = document.getElementById('atHandlePrefix'); 14279 if (!prefixEl) return; 14280 14281 // Sample handles to show (fetched from PDS or fallback) 14282 let handles = []; 14283 14284 // Try to fetch real handles from the PDS 14285 try { 14286 const res = await fetch('https://at.aesthetic.computer/xrpc/com.atproto.sync.listRepos?limit=100'); 14287 if (res.ok) { 14288 const data = await res.json(); 14289 // Extract handles from repo DIDs (we'll resolve them or use fallbacks) 14290 handles = (data.repos || []) 14291 .map(r => r.handle) 14292 .filter(h => h && !h.startsWith('did:') && h.endsWith('.aesthetic.computer')) 14293 .map(h => h.replace('.aesthetic.computer', '')) 14294 .filter(h => h && h !== 'at' && h !== 'art.at') 14295 .slice(0, 20); 14296 } 14297 } catch (e) { 14298 } 14299 14300 // Fallback handles if API fails or returns nothing 14301 if (!handles.length) { 14302 handles = ['jeffrey', 'kai', 'luna', 'zen', 'pixel', 'nova']; 14303 } 14304 14305 let currentIndex = 0; 14306 const defaultHandle = 'art'; 14307 let showingArt = true; // Toggle between user and art 14308 const scrambleChars = 'abcdefghijklmnopqrstuvwxyz0123456789'; 14309 14310 function renderHandle(handle, isDefault = false) { 14311 // Create colored spans for each character with staggered animation delay 14312 const charClass = isDefault ? 'at-char-gray' : 'at-char'; 14313 return handle.split('').map((char, i) => { 14314 const delay = (i * 0.3).toFixed(1); 14315 return `<span class="${charClass}" style="animation-delay:-${delay}s">${char}</span>`; 14316 }).join(''); 14317 } 14318 14319 function scrambleTo(targetHandle, isDefault) { 14320 const charClass = isDefault ? 'at-char-gray' : 'at-char'; 14321 const maxLen = Math.max(prefixEl.textContent.length, targetHandle.length); 14322 let iterations = 0; 14323 const maxIterations = 6; 14324 14325 const scrambleInterval = setInterval(() => { 14326 iterations++; 14327 let result = ''; 14328 14329 for (let i = 0; i < maxLen; i++) { 14330 const targetChar = targetHandle[i] || ''; 14331 const progress = iterations / maxIterations; 14332 const charSettled = i < targetHandle.length && (iterations > maxIterations - 2 + i * 0.3); 14333 14334 if (charSettled || iterations >= maxIterations) { 14335 // Show final character 14336 if (i < targetHandle.length) { 14337 const delay = (i * 0.3).toFixed(1); 14338 result += `<span class="${charClass}" style="animation-delay:-${delay}s">${targetChar}</span>`; 14339 } 14340 } else { 14341 // Show random scramble character 14342 const randChar = scrambleChars[Math.floor(Math.random() * scrambleChars.length)]; 14343 const delay = (i * 0.3).toFixed(1); 14344 result += `<span class="${charClass}" style="animation-delay:-${delay}s">${randChar}</span>`; 14345 } 14346 } 14347 14348 prefixEl.innerHTML = result; 14349 14350 if (iterations >= maxIterations) { 14351 clearInterval(scrambleInterval); 14352 } 14353 }, 30); 14354 } 14355 14356 function cycleHandle() { 14357 // Alternate: username -> art -> username -> art 14358 if (handles.length === 0) { 14359 // No handles, just scramble to art 14360 scrambleTo(defaultHandle, true); 14361 } else if (showingArt) { 14362 // Scramble to next username (colorful) 14363 scrambleTo(handles[currentIndex], false); 14364 currentIndex = (currentIndex + 1) % handles.length; 14365 showingArt = false; 14366 } else { 14367 // Scramble to art (gray) 14368 scrambleTo(defaultHandle, true); 14369 showingArt = true; 14370 } 14371 } 14372 14373 // Initial render - start with gray 'art' 14374 prefixEl.innerHTML = renderHandle(defaultHandle, true); 14375 prefixEl.classList.add('visible'); 14376 showingArt = true; 14377 14378 // Show after a short delay 14379 setTimeout(() => prefixEl.classList.add('visible'), 500); 14380 14381 // Cycle every 2 seconds 14382 setInterval(cycleHandle, 2000); 14383 })(); 14384 </script> 14385 14386 <script> 14387 // Fetch latest mug for invest section 14388 (async function loadInvestMug() { 14389 const mugImage = document.getElementById('investMugImage'); 14390 const mugIcon = document.getElementById('investMugIcon'); 14391 const mugCaption = document.getElementById('investMugCaption'); 14392 const mugLink = document.getElementById('investMug'); 14393 if (!mugImage || !mugIcon) return; 14394 14395 // Use localhost in dev, production otherwise 14396 const isDev = window.location.hostname === 'localhost' || window.location.hostname.includes('local'); 14397 const baseUrl = isDev ? 'https://localhost:8888' : 'https://aesthetic.computer'; 14398 14399 // Update the link href to use correct domain 14400 if (mugLink) { 14401 mugLink.href = `${baseUrl}/+`; 14402 } 14403 14404 let mug = null; 14405 14406 try { 14407 const res = await fetch(`${baseUrl}/api/mugs?limit=20`); 14408 if (res.ok) { 14409 const data = await res.json(); 14410 // Find first mug with a preview image 14411 mug = (data.mugs || []).find(m => m.preview); 14412 if (mug) { 14413 mugImage.src = mug.preview; 14414 mugImage.style.display = 'block'; 14415 mugIcon.style.display = 'none'; 14416 14417 // Update link to go directly to this mug's page 14418 if (mugLink && mug.code) { 14419 mugLink.href = `${baseUrl}/+${mug.code}`; 14420 } 14421 14422 // Update caption with real data 14423 // Format: "white mug of #CODE in $viaCode" matching mug.mjs 14424 // sourceCode is the painting hash (e.g., "sb9"), via is the kidlisp code (e.g., "bop") 14425 if (mugCaption) { 14426 const colorLower = (mug.color || 'white').toLowerCase(); 14427 // Show sourceCode (painting hash) as main code, via is the kidlisp source 14428 const displayCode = mug.sourceCode || mug.code; 14429 const viaCode = mug.via; 14430 // Character-colored code: # in cyan, code chars in light blue 14431 const colorizeCode = (code) => { 14432 return code.split('').map(c => `<span class="char-code">${c}</span>`).join(''); 14433 }; 14434 const colorizeVia = (via) => { 14435 return via.split('').map(c => `<span class="char-via">${c}</span>`).join(''); 14436 }; 14437 // Add # prefix for painting codes (short codes are user paintings) 14438 const hasPrefix = displayCode && displayCode.length < 8; 14439 let codeHtml = hasPrefix ? `<span class="char-hash">#</span>${colorizeCode(displayCode)}` : colorizeCode(displayCode); 14440 let captionHtml = `<span class="mug-color">${colorLower}</span><span class="mug-of"> mug of </span><span class="mug-code">${codeHtml}</span>`; 14441 if (viaCode) { 14442 captionHtml += `<span class="mug-via"> in <span class="char-dollar">$</span>${colorizeVia(viaCode)}</span>`; 14443 } 14444 mugCaption.innerHTML = captionHtml; 14445 } 14446 } 14447 } 14448 } catch (e) { 14449 } 14450 14451 // Link always goes to /+ (singular mug route) 14452 })(); 14453 </script> 14454</body> 14455</html>