openvibers/openvibe.live Commits
About section: cut the repetition, fix the phone layout, and stop two dead cross-site requests
Changes
@@@ -2546,18 +2546,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.home-cta-banner .cta-hero,.home-cta-banner .cta-method strong,.home-cta-banner .home-cta-feature > strong,--.home-cta-banner .cta-robot-body strong,--.home-cta-banner .cta-owner-body strong,--.home-cta-banner .cta-owner-body b,.home-cta-banner .cta-lede strong { color: #f4f2ef; }.home-cta-banner .cta-lede,--.home-cta-banner .cta-section-label,--.home-cta-banner .cta-robot-body p,--.home-cta-banner .cta-owner-body p { color: #bcbab6; }++.home-cta-banner .cta-section-label { color: #bcbab6; }.home-cta-banner .cta-method span,.home-cta-banner .home-cta-feature > span { color: #918f8b; }--.home-cta-banner .cta-method span b,--.home-cta-banner .cta-owner-body b { color: #d6d4d0; }++.home-cta-banner .cta-method span b { color: #d6d4d0; }.home-cta-banner .btn-outline { color: #e8e6e3; border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: rgba(255,255,255,0.03); }.home-cta-banner .btn-outline:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: var(--accent); color: #fff; }@@@ -2620,22 +2614,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.cta-method-tag { margin-top: 10px; display: inline-block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.03em; color: var(--accent-light); background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); border-radius: 999px; padding: 3px 10px; }/* Robot / low-latency highlight */--.cta-robot {-- position: relative; overflow: hidden; text-align: left;-- display: flex; gap: 18px; align-items: center;-- margin: 22px 0 6px; padding: 20px 22px; border-radius: 16px;-- background: linear-gradient(120deg, color-mix(in srgb, var(--accent-dark) 14%, transparent), rgba(52,152,219,0.10) 80%);-- border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);--}--.cta-robot-glow { position: absolute; width: 320px; height: 320px; right: -80px; top: -120px; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--accent) 35%, transparent), transparent 65%); filter: blur(20px); animation: ctaGlowPulse 4.5s ease-in-out infinite; pointer-events: none; }--@keyframes ctaGlowPulse { 0%,100% { opacity: 0.5; transform: scale(1); } 50% { opacity: 0.9; transform: scale(1.12); } }--.cta-robot-icon { flex-shrink: 0; width: 60px; height: 60px; border-radius: 16px; display: flex; align-items: center; justify-content: center; font-size: 1.7rem; color: #0e0e12; background: linear-gradient(135deg, var(--accent-light), var(--accent-dark)); box-shadow: 0 10px 26px color-mix(in srgb, var(--accent-dark) 40%, transparent); animation: ctaFloat 3.6s ease-in-out infinite; }--@keyframes ctaFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }--.cta-robot-body { position: relative; z-index: 1; }--.cta-robot-body strong { display: block; font-size: 1.15rem; color: var(--text-primary); margin-bottom: 4px; }--.cta-robot-body p { margin: 0; font-size: 0.9rem; color: var(--text-secondary); line-height: 1.6; }--.cta-robot-body b { color: var(--accent-light); }--/* Feature grid */.home-cta-features {display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px;@@@ -2672,29 +2650,19 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.home-cta-feature.feat-star { border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 8%, transparent), rgba(255,255,255,0.02)); }.home-cta-feature.feat-star::before { content: '\f005'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; top: 12px; right: 13px; font-size: 0.6rem; color: var(--accent); opacity: 0.8; }--/* Owner / community panel */--.cta-owner {-- position: relative; overflow: hidden; text-align: left;-- display: flex; gap: 18px; align-items: center;-- margin: 6px 0 26px; padding: 20px 22px; border-radius: 16px;-- background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 14%, transparent), rgba(18,16,14,0.6));-- border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);--}--.cta-owner-shine { position: absolute; inset: 0; background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.08) 50%, transparent 65%); transform: translateX(-120%); animation: ctaSweep 5.5s ease-in-out infinite; pointer-events: none; }--@keyframes ctaSweep { 0% { transform: translateX(-120%); } 55%,100% { transform: translateX(120%); } }--.cta-owner-avatar { flex-shrink: 0; width: 58px; height: 58px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: #0e0e12; background: linear-gradient(135deg, var(--accent-light), var(--accent)); box-shadow: 0 8px 22px color-mix(in srgb, var(--accent) 40%, transparent); }--.cta-owner-body { position: relative; z-index: 1; }--.cta-owner-body strong { display: block; font-size: 1.1rem; color: var(--text-primary); margin-bottom: 4px; }--.cta-owner-body p { margin: 0; font-size: 0.9rem; color: var(--text-secondary); line-height: 1.65; }--.cta-owner-body b { color: var(--text-primary); }--.cta-owner-body em { color: var(--accent-light); font-style: normal; font-weight: 700; }--.cta-heart { color: #ff5a7a; animation: ctaHeart 1.4s ease-in-out infinite; display: inline-block; }--@keyframes ctaHeart { 0%,100% { transform: scale(1); } 25% { transform: scale(1.25); } 40% { transform: scale(1); } }--/* Buttons */.home-cta-buttons { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }--.cta-btn-glow { position: relative; box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); animation: ctaBtnGlow 2.6s ease-in-out infinite; }--@keyframes ctaBtnGlow { 0%,100% { box-shadow: 0 8px 22px color-mix(in srgb, var(--accent-dark) 35%, transparent), 0 0 0 0 transparent; } 50% { box-shadow: 0 8px 30px color-mix(in srgb, var(--accent-dark) 55%, transparent), 0 0 22px 2px color-mix(in srgb, var(--accent) 25%, transparent); } }++/* The glow used to animate box-shadow, which repaints the button every frame for as long as it is++ on screen. The shadow is now static and the pulse is an opacity fade on a pseudo-element halo. */++.cta-btn-glow { position: relative; isolation: isolate; box-shadow: 0 8px 22px color-mix(in srgb, var(--accent-dark) 35%, transparent); }++.cta-btn-glow::after {++ /* No spread and no outset: with either, the halo's edge reads as a hard ring around the button++ at the top of the pulse instead of a glow. */++ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;++ box-shadow: 0 10px 34px color-mix(in srgb, var(--accent-dark) 50%, transparent), 0 0 30px color-mix(in srgb, var(--accent) 28%, transparent);++ opacity: 0; animation: ctaBtnGlow 2.6s ease-in-out infinite;++}++@keyframes ctaBtnGlow { 0%,100% { opacity: 0; } 50% { opacity: 1; } }@media (max-width: 860px) {.cta-methods { grid-template-columns: 1fr; }@@@ -2702,14 +2670,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }}@media (max-width: 560px) {.home-cta-features { grid-template-columns: 1fr; }-- .cta-robot, .cta-owner { flex-direction: column; text-align: center; }-- .cta-robot-body, .cta-owner-body { text-align: center; }.cta-hero { font-size: 1.7rem; }}/* Respect reduced-motion */@media (prefers-reduced-motion: reduce) {.home-cta-banner::before, .cta-ambient, .cta-embers span, .cta-live-dot, .cta-hero-grad,-- .cta-robot-glow, .cta-robot-icon, .cta-owner-shine, .cta-heart, .cta-btn-glow, .home-cta-feature {++ .cta-btn-glow, .home-cta-feature {animation: none !important;}.cta-hero-grad { -webkit-text-fill-color: var(--accent); color: var(--accent); }@@@ -15716,3 +15682,113 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg.user-dropdown.show, .user-dropdown.show .ud-group a { animation: none !important; }.user-dropdown .ud-group a:hover { transform: none; }}++++/* ═══════════════════════════════════════════════════════════════++ About section ("Streaming the way it should be") — layout pass.++++ Measured before: 1,875px tall on desktop and 4,173px on a phone, roughly five screens. Much of++ that was repetition — a robot callout restating the starred robots card, an owner callout++ restating the lede, a tools button restating the tools card — which is gone from the markup.++ The rest was card shape: on a phone each method card was ~470px, an icon stacked above a title++ stacked above a paragraph above a tag. Phones now get compact rows with the icon beside the++ text, which is how a list of short items reads on a narrow screen anyway.++ ═══════════════════════════════════════════════════════════════ */++++.cta-btn-short { display: none; }++++/* Method cards: a column, so the tag always lands on the bottom edge. It used to sit inline after++ the last word of the description in two cards and on its own line in the third. */++.cta-method { display: flex; flex-direction: column; }++.cta-method > span { display: block; }++.cta-method-tag { display: block; width: fit-content; margin-top: auto; padding-top: 3px; }++.cta-method > span + .cta-method-tag { margin-top: 12px; }++.cta-method-icon { transition: transform .25s cubic-bezier(.2, 1.3, .3, 1), box-shadow .25s ease; }++.cta-method:hover .cta-method-icon { transform: rotate(-6deg) scale(1.08); box-shadow: 0 10px 24px color-mix(in srgb, var(--accent-dark) 55%, transparent); }++.cta-method:hover .cta-method-num { color: color-mix(in srgb, var(--accent) 32%, transparent); }++.cta-method-num { transition: color .2s ease; }++++/* Feature cards: the icon gets the same rounded tile the rest of the site uses for section icons,++ instead of a bare glyph floating above the title. */++.home-cta-feature > i:first-child {++ width: 34px; height: 34px; flex: none; border-radius: 10px;++ display: grid; place-items: center; font-size: 0.92rem; margin-bottom: 6px;++ color: var(--accent);++ background: color-mix(in srgb, var(--accent) 13%, transparent);++ border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);++ transition: transform .25s cubic-bezier(.2, 1.3, .3, 1), background .2s ease;++}++.home-cta-feature:hover > i:first-child { transform: scale(1.1) rotate(-5deg); background: color-mix(in srgb, var(--accent) 22%, transparent); }++++/* A sheen that crosses a card once on hover — transform only, clipped to the card. */++.cta-method::before, .home-cta-feature::after {++ content: ''; position: absolute; inset: 0; pointer-events: none; border-radius: inherit;++ background: linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.06) 50%, transparent 60%);++ transform: translateX(-100%); opacity: 0;++}++.home-cta-feature { overflow: hidden; }++.cta-method:hover::before, .home-cta-feature:hover::after { opacity: 1; animation: ctaCardSheen .8s ease-out; }++@keyframes ctaCardSheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }++++/* Money section: a sentence and three short points, not another wall of feature cards. The lede++ was the one centred paragraph in a section that is otherwise left-aligned. */++.cta-money-lede {++ max-width: 700px; margin: 0 0 14px !important; text-align: left;++ padding: 2px 0 2px 14px; border-left: 3px solid color-mix(in srgb, #4ade80 55%, transparent);++ font-size: 0.95rem; line-height: 1.6;++}++.cta-money-grid .home-cta-feature > i:first-child {++ color: #4ade80; background: color-mix(in srgb, #4ade80 12%, transparent); border-color: color-mix(in srgb, #4ade80 26%, transparent);++}++.cta-money-grid { margin-bottom: 4px !important; }++++.home-cta-buttons { margin: 22px 0 6px; }++++/* Cards arrive when the section is opened. They used to play a rise animation at page load,++ while the body was collapsed to zero height — an animation nobody could see. */++.home-cta-feature { animation: none; }++.home-cta-banner.about-opening .cta-method,++.home-cta-banner.about-opening .home-cta-feature,++.home-cta-banner.about-opening .cta-section-label,++.home-cta-banner.about-opening .home-cta-buttons,++.home-cta-banner.about-opening .cta-money-lede {++ animation: ctaRise .5s cubic-bezier(.22, 1, .36, 1) both;++ animation-delay: calc(var(--about-i, 0) * 38ms);++}++++@media (max-width: 640px) {++ /* Methods and features become compact rows: icon beside the text. */++ .cta-methods { gap: 9px; }++ .cta-method {++ display: grid; grid-template-columns: auto 1fr; column-gap: 12px; row-gap: 3px;++ padding: 13px 14px; align-items: start;++ }++ .cta-method-icon { grid-row: 1 / span 3; width: 38px; height: 38px; font-size: 1rem; margin: 0; border-radius: 10px; }++ .cta-method-num { top: 9px; right: 11px; font-size: 1rem; }++ .cta-method strong { font-size: 0.93rem; margin: 0; padding-right: 26px; }++ .cta-method > span { font-size: 0.8rem; line-height: 1.45; }++ .cta-method-tag, .cta-method > span + .cta-method-tag { margin-top: 5px; }++++ .home-cta-features { gap: 8px !important; }++ .home-cta-feature {++ display: grid; grid-template-columns: auto 1fr; column-gap: 12px; row-gap: 2px;++ padding: 12px 13px !important; align-items: start;++ }++ .home-cta-feature > i:first-child { grid-row: 1 / span 2; margin: 0; width: 34px; height: 34px; }++ .home-cta-feature > strong { font-size: 0.88rem; padding-right: 14px; }++ .home-cta-feature > span { font-size: 0.78rem; line-height: 1.45; }++ .home-cta-feature.feat-star::before { top: 10px; right: 11px; }++++ .home-cta-buttons { gap: 8px; }++ .home-cta-buttons > .btn { flex: 1 1 140px; justify-content: center; padding-left: 12px; padding-right: 12px; white-space: nowrap; }++ .cta-btn-long { display: none; }++ .cta-btn-short { display: inline; }++ .cta-money-lede { font-size: 0.86rem; padding-left: 11px; }++ .cta-section-label { white-space: nowrap; }++}++++@media (prefers-reduced-motion: reduce) {++ .cta-method:hover::before, .home-cta-feature:hover::after { animation: none; opacity: 0; }++ .home-cta-banner.about-opening * { animation: none !important; }++ .cta-method:hover .cta-method-icon, .home-cta-feature:hover > i:first-child { transform: none; }++}++@@@ -88,7 +88,7 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat it as non-critical, and the onload flips it back to all once it has arrived. --><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>-- <link rel="stylesheet" href="/css/style.css?v=208">++ <link rel="stylesheet" href="/css/style.css?v=211"><link rel="stylesheet" href="/css/home-fx.css?v=14"><link rel="stylesheet" href="/css/home-tour.css?v=10"><link rel="stylesheet" href="/css/i18n-star.css?v=11">@@@ -444,7 +444,7 @@<div class="home-about-body" id="home-about-body"><!-- Three ways to go live -->-- <div class="cta-section-label" style="--sc:#60a5fa"><i class="fa-solid fa-tower-broadcast"></i> Three ways to go live — pick your vibe</div>++ <div class="cta-section-label" style="--sc:#60a5fa"><i class="fa-solid fa-tower-broadcast"></i> Three ways to go live</div><div class="cta-methods"><div class="cta-method"><div class="cta-method-num">01</div>@@@ -469,23 +469,13 @@</div></div>-- <!-- Killer feature: real-time hardware control -->-- <div class="cta-robot">-- <div class="cta-robot-glow" aria-hidden="true"></div>-- <div class="cta-robot-icon"><i class="fa-solid fa-robot"></i></div>-- <div class="cta-robot-body">-- <strong>Viewers control real hardware — live.</strong>-- <p>That same sub-second WebRTC pipeline isn't just for talking heads. Viewers can <b>drive robots, pan cameras, and operate interactive rigs in real time</b> — latency low enough to actually <em>play</em>. Ultra-low-latency streaming built for robotics, PTZ cameras, and interactive apps. Good luck doing <i>that</i> on Twitch.</p>-- </div>-- </div>--<!-- Differentiators -->-- <div class="cta-section-label cta-diff-label" style="--sc:#fbbf24"><i class="fa-solid fa-crown"></i> Stuff you won't find on Twitch, Kick or YouTube</div>++ <div class="cta-section-label cta-diff-label" style="--sc:#fbbf24"><i class="fa-solid fa-crown"></i> Only on OpenVibe</div><div class="home-cta-features"><div class="home-cta-feature feat-star"><i class="fa-solid fa-robot"></i><strong>Viewer-controlled robots</strong>-- <span>Hand your chat the controls — real-time WebRTC command of robots, cameras & hardware. Genuinely nobody else does this.</span>++ <span>Hand chat the controls: robots, PTZ cameras and interactive rigs over the same sub-second WebRTC pipeline — low enough latency to actually play.</span></div><div class="home-cta-feature"><i class="fa-solid fa-tower-broadcast"></i>@@@ -494,8 +484,8 @@</div><div class="home-cta-feature"><i class="fa-solid fa-gamepad"></i>-- <strong>A game built into the site</strong>-- <span>Viewers play Scraplandia together, live on-stream, with leaderboards and a shared canvas. Chat that actually does something.</span>++ <strong>A game next door</strong>++ <span>Scraplandia lives on OpenVibe.Games — same account, leaderboards and a shared canvas, one click from any stream.</span></div><div class="home-cta-feature"><i class="fa-solid fa-earth-americas"></i>@@@ -525,36 +515,23 @@<div class="home-cta-feature feat-star"><i class="fa-solid fa-code"></i><strong>100% open source</strong>-- <span>Every line is public on GitHub. No black boxes, no hidden trackers, no selling your data to anyone.</span>-- </div>-- </div>---- <!-- Owner / community -->-- <div class="cta-owner">-- <div class="cta-owner-shine" aria-hidden="true"></div>-- <div class="cta-owner-avatar"><i class="fa-solid fa-mug-hot"></i></div>-- <div class="cta-owner-body">-- <strong>Run by a human, not a boardroom.</strong>-- <p>OpenVibe.Live is built & maintained by one hobbyist programmer who's usually <b>right here in chat</b>. Laid-back rules, no corporate nonsense, and a real person who <em>actually wants your feedback</em>. Got an idea? Say it in a stream — features here get shipped because a <b>user asked for them</b>. You help shape OpenVibe.Live and the whole OpenVibe. <span class="cta-heart">♥</span></p>++ <span>Every line is public on GitHub. Read it, fork it, or send a pull request — features here get built because someone asked.</span></div></div><div class="home-cta-buttons">-- <button class="btn btn-primary btn-lg cta-btn-glow" onclick="currentUser ? navigate('/broadcast') : showModal('register')">-- <i class="fa-solid fa-broadcast-tower"></i> Launch your stream++ <button class="btn btn-primary btn-lg cta-btn-glow" onclick="if (window.startGoLiveJourney) startGoLiveJourney(); else if (currentUser) navigate('/broadcast'); else showModal('register');">++ <i class="fa-solid fa-broadcast-tower"></i> <span class="cta-btn-long">Launch your stream</span><span class="cta-btn-short">Go live</span></button><a href="/chat" class="btn btn-outline btn-lg" onclick="return handleLinkClick(event, '/chat')"><i class="fa-solid fa-comments"></i> Say hi in chat</a>-- <a href="https://openvibe.network" class="btn btn-outline btn-lg">-- <i class="fa-solid fa-screwdriver-wrench"></i> 200+ free tools-- </a></div><!-- Where the money goes — part of the same About section --><div class="cta-section-label cta-diff-label" style="--sc:#4ade80"><i class="fa-solid fa-seedling"></i> Where the money goes</div>-- <p class="cta-lede">Open source, run in the open, and built to stay that way. Whatever the network earns goes back into it — keeping the servers on, paying for the storage and bandwidth that free streaming actually costs, funding new features, and putting money into the creators and events that make this place worth showing up to.</p>-- <div class="home-cta-features">++ <p class="cta-lede cta-money-lede">Whatever the network earns goes back into it — the servers, the storage and bandwidth that free streaming really costs, new features, and the creators and events that make this place worth showing up to.</p>++ <div class="home-cta-features cta-money-grid"><div class="home-cta-feature"><i class="fa-solid fa-plug-circle-bolt"></i><strong>Keep it running</strong>@@@ -567,8 +544,8 @@</div><div class="home-cta-feature"><i class="fa-solid fa-code-branch"></i>-- <strong>Stay open, stay decent</strong>-- <span>Every line is open source and the analytics are public. No selling the community out, no surprise pivot, no dark patterns — the code is right there if you want to check.</span>++ <strong>No surprise pivot</strong>++ <span>Where it goes stays visible. No selling the community out, no bait-and-switch, no dark patterns.</span></div></div></div><!-- /home-about-body -->@@@ -614,14 +591,14 @@<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div><!-- Scraplandia Leaderboards -->-- <div class="section-header" style="--sc:#fbbf24" id="home-quest-header" style="display:none">++ <div class="section-header" style="--sc:#fbbf24;display:none" id="home-quest-header"><h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards<small class="section-kicker">the ladder</small></h2><a href="https://openvibe.games/game" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Play Game</a></div><div class="home-leaderboards" id="home-leaderboards"></div><!-- Canvas Preview -->-- <div class="section-header" style="--sc:#4ade80" id="home-canvas-header" style="display:none">++ <div class="section-header" style="--sc:#4ade80;display:none" id="home-canvas-header"><h2><i class="fa-solid fa-palette"></i> Community Canvas<small class="section-kicker">drawn together</small></h2><a href="https://openvibe.games/canvas" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Open Canvas</a></div>@@@ -5160,7 +5137,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig};})();</script>--<script src="/js/app.js?v=176" defer></script>++<script src="/js/app.js?v=178" defer></script><script src="/js/stream-player.js?v=22" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -2335,17 +2335,37 @@ function _initHomeAbout() {}} catch { /* observer optional */ }}++/**++ * Stagger the About cards in as the section opens.++ *++ * Each animated child gets an index so they arrive in reading order; the class comes off on a++ * timer so a card whose animation never ran is not left holding its from-state.++ */++function _aboutPlayOpening(banner) {++ let reduce = false;++ try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }++ if (reduce) return;++ const items = banner.querySelectorAll('.home-about-body .cta-section-label, .home-about-body .cta-method, .home-about-body .home-cta-feature, .home-about-body .home-cta-buttons, .home-about-body .cta-money-lede');++ items.forEach((el, i) => el.style.setProperty('--about-i', String(Math.min(i, 16))));++ banner.classList.remove('about-opening'); void banner.offsetWidth;++ banner.classList.add('about-opening');++ clearTimeout(banner._aboutTimer);++ banner._aboutTimer = setTimeout(() => banner.classList.remove('about-opening'), 1400);++}function toggleHomeAbout() {const banner = document.getElementById('home-cta-banner');if (!banner) return;const collapsed = banner.classList.toggle('about-collapsed');const toggle = banner.querySelector('.home-about-toggle');if (toggle) toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');++ if (!collapsed) _aboutPlayOpening(banner);}function openHomeAbout() {const banner = document.getElementById('home-cta-banner');if (!banner) return;++ const wasCollapsed = banner.classList.contains('about-collapsed');banner.classList.remove('about-collapsed');++ if (wasCollapsed) _aboutPlayOpening(banner);const toggle = banner.querySelector('.home-about-toggle');if (toggle) toggle.setAttribute('aria-expanded', 'true');banner.scrollIntoView({ behavior: 'smooth', block: 'start' });@@@ -2908,9 +2928,11 @@ async function loadHomeLeaderboards() {try {const boards = ['total_level', 'combat', 'mining', 'fishing'];const questUrl = getScraplandiaUrl();-- const results = await Promise.all(boards.map(b =>-- fetch(`${questUrl}/api/game/leaderboard/${b}`).then(r => r.json()).catch(() => ({ entries: [] }))-- ));++ // One probe first: if the service is not answering, the other three would fail the same way.++ const first = await fetchServiceJson(`${questUrl}/api/game/leaderboard/${boards[0]}`);++ const rest = first ? await Promise.all(boards.slice(1).map(b => fetchServiceJson(`${questUrl}/api/game/leaderboard/${b}`))) : [];++ const results = [first, ...rest].map(r => r || { entries: [] });++ while (results.length < boards.length) results.push({ entries: [] });const header = document.getElementById('home-quest-header');const container = document.getElementById('home-leaderboards');const hasData = results.some(r => r.entries && r.entries.length);@@@ -2939,11 +2961,40 @@ async function loadHomeLeaderboards() {} catch { /* silent */ }}++/**++ * Fetch JSON from another OpenVibe service, remembering when it is not there.++ *++ * The home page asks OpenVibe.Games for leaderboards and canvas state. Those endpoints currently++ * answer with the Games SPA's HTML and no CORS headers, so from openvibe.live all five requests++ * fail — on every single home page view. Failing is fine; failing five times per visit forever is++ * not. A miss is remembered for a few hours per browser, so the page stops asking a service that++ * is not answering and starts again on its own once the window lapses. A response that is not++ * JSON counts as a miss instead of throwing inside .json().++ */++const _XSVC_MISS_KEY = 'ov_xsvc_miss_v1';++const _XSVC_MISS_MS = 6 * 60 * 60 * 1000;++function _xsvcMisses() { try { return JSON.parse(localStorage.getItem(_XSVC_MISS_KEY) || '{}'); } catch { return {}; } }++async function fetchServiceJson(url) {++ let origin = url;++ try { origin = new URL(url).origin + new URL(url).pathname.split('/').slice(0, 3).join('/'); } catch { /* */ }++ const misses = _xsvcMisses();++ if (misses[origin] && Date.now() - misses[origin] < _XSVC_MISS_MS) return null;++ try {++ const r = await fetch(url, { credentials: 'omit' });++ const type = r.headers.get('content-type') || '';++ if (!r.ok || !/json/i.test(type)) throw new Error('not json');++ return await r.json();++ } catch {++ try { misses[origin] = Date.now(); localStorage.setItem(_XSVC_MISS_KEY, JSON.stringify(misses)); } catch { /* */ }++ return null;++ }++}++async function loadHomeCanvas() {try {-- const data = await fetch(`${getScraplandiaUrl()}/api/game/canvas/state`).then(r => r.json());const header = document.getElementById('home-canvas-header');const container = document.getElementById('home-canvas-preview');++ const data = await fetchServiceJson(`${getScraplandiaUrl()}/api/game/canvas/state`);if (!data || !data.board) { if (header) header.style.display = 'none'; return; }if (header) header.style.display = '';@@@ -2983,7 +3034,10 @@ async function loadHomeCanvas() {ctx.fillRect(tile.x * scale, tile.y * scale, scale, scale);}}-- } catch { /* silent */ }++ } catch {++ const header = document.getElementById('home-canvas-header');++ if (header) header.style.display = 'none';++ }}// Markup for a single stream card (live or recent). Extracted so the home pagennew file mode 100644@@@ -0,0 +1,47 @@++/**++ * Duplicate attributes on one element.++ *++ * An HTML parser keeps the first occurrence of an attribute and silently drops the rest, so++ * `<div style="--sc:#fff" style="display:none">` renders visible. This has shipped three times on++ * the home page — the changelog header lost its margin, and the canvas and leaderboard headers lost++ * `display:none` and showed as titles over empty sections whenever their loaders failed. Nothing in++ * a browser reports it, so it is checked here.++ *++ * node test/html-attributes.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const PUBLIC = path.join(__dirname, '..', 'public');++const files = fs.readdirSync(PUBLIC).filter(f => f.endsWith('.html'));++let problems = [];++++for (const f of files) {++ const src = fs.readFileSync(path.join(PUBLIC, f), 'utf8');++ // Strip comments, scripts and styles so their contents are not read as tags.++ const clean = src++ .replace(/<!--[\s\S]*?-->/g, m => m.replace(/[^\n]/g, ' '))++ .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, m => m.replace(/[^\n]/g, ' '));++ const tagRe = /<([a-zA-Z][\w-]*)(\s[^<>]*?)?\/?>/g;++ let m;++ while ((m = tagRe.exec(clean))) {++ const attrs = m[2] || '';++ // Remove quoted values before collecting names, so "a=b" inside a value is not a name.++ const bare = attrs.replace(/"[^"]*"|'[^']*'/g, '""');++ const names = [...bare.matchAll(/([^\s"'=<>\/]+)\s*(?==|\s|$)/g)].map(x => x[1].toLowerCase());++ const seen = new Set();++ for (const n of names) {++ if (seen.has(n)) {++ const line = clean.slice(0, m.index).split('\n').length;++ problems.push(`${f}:${line} <${m[1]}> has "${n}" twice`);++ break;++ }++ seen.add(n);++ }++ }++}++++assert.deepStrictEqual(problems, [], 'duplicate attributes found:\n ' + problems.join('\n '));++console.log(` ok - no duplicate attributes across ${files.length} HTML files`);
@@ -2546,18 +2546,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2546,18 +2546,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.home-cta-banner .cta-hero,
.home-cta-banner .cta-hero,
.home-cta-banner .cta-method strong,
.home-cta-banner .cta-method strong,
.home-cta-banner .home-cta-feature > strong,
.home-cta-banner .home-cta-feature > strong,
-.home-cta-banner .cta-robot-body strong,
-.home-cta-banner .cta-owner-body strong,
-.home-cta-banner .cta-owner-body b,
.home-cta-banner .cta-lede strong { color: #f4f2ef; }
.home-cta-banner .cta-lede strong { color: #f4f2ef; }
.home-cta-banner .cta-lede,
.home-cta-banner .cta-lede,
-.home-cta-banner .cta-section-label,
-.home-cta-banner .cta-robot-body p,
-.home-cta-banner .cta-owner-body p { color: #bcbab6; }
+.home-cta-banner .cta-section-label { color: #bcbab6; }
.home-cta-banner .cta-method span,
.home-cta-banner .cta-method span,
.home-cta-banner .home-cta-feature > span { color: #918f8b; }
.home-cta-banner .home-cta-feature > span { color: #918f8b; }
-.home-cta-banner .cta-method span b,
-.home-cta-banner .cta-owner-body b { color: #d6d4d0; }
+.home-cta-banner .cta-method span b { color: #d6d4d0; }
.home-cta-banner .btn-outline { color: #e8e6e3; border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: rgba(255,255,255,0.03); }
.home-cta-banner .btn-outline { color: #e8e6e3; border-color: color-mix(in srgb, var(--accent) 40%, transparent); background: rgba(255,255,255,0.03); }
.home-cta-banner .btn-outline:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: var(--accent); color: #fff; }
.home-cta-banner .btn-outline:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: var(--accent); color: #fff; }
@@ -2620,22 +2614,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2620,22 +2614,6 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.cta-method-tag { margin-top: 10px; display: inline-block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.03em; color: var(--accent-light); background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); border-radius: 999px; padding: 3px 10px; }
.cta-method-tag { margin-top: 10px; display: inline-block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.03em; color: var(--accent-light); background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); border-radius: 999px; padding: 3px 10px; }
/* Robot / low-latency highlight */
/* Robot / low-latency highlight */
-.cta-robot {
- position: relative; overflow: hidden; text-align: left;
- display: flex; gap: 18px; align-items: center;
- margin: 22px 0 6px; padding: 20px 22px; border-radius: 16px;
- background: linear-gradient(120deg, color-mix(in srgb, var(--accent-dark) 14%, transparent), rgba(52,152,219,0.10) 80%);
- border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
-}
-.cta-robot-glow { position: absolute; width: 320px; height: 320px; right: -80px; top: -120px; border-radius: 50%; background: radial-gradient(circle, color-mix(in srgb, var(--accent) 35%, transparent), transparent 65%); filter: blur(20px); animation: ctaGlowPulse 4.5s ease-in-out infinite; pointer-events: none; }
-@keyframes ctaGlowPulse { 0%,100% { opacity: 0.5; transform: scale(1); } 50% { opacity: 0.9; transform: scale(1.12); } }
-.cta-robot-icon { flex-shrink: 0; width: 60px; height: 60px; border-radius: 16px; display: flex; align-items: center; justify-content: center; font-size: 1.7rem; color: #0e0e12; background: linear-gradient(135deg, var(--accent-light), var(--accent-dark)); box-shadow: 0 10px 26px color-mix(in srgb, var(--accent-dark) 40%, transparent); animation: ctaFloat 3.6s ease-in-out infinite; }
-@keyframes ctaFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
-.cta-robot-body { position: relative; z-index: 1; }
-.cta-robot-body strong { display: block; font-size: 1.15rem; color: var(--text-primary); margin-bottom: 4px; }
-.cta-robot-body p { margin: 0; font-size: 0.9rem; color: var(--text-secondary); line-height: 1.6; }
-.cta-robot-body b { color: var(--accent-light); }
-
/* Feature grid */
/* Feature grid */
.home-cta-features {
.home-cta-features {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px;
display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px;
@@ -2672,29 +2650,19 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2672,29 +2650,19 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.home-cta-feature.feat-star { border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 8%, transparent), rgba(255,255,255,0.02)); }
.home-cta-feature.feat-star { border-color: color-mix(in srgb, var(--accent) 30%, transparent); background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 8%, transparent), rgba(255,255,255,0.02)); }
.home-cta-feature.feat-star::before { content: '\f005'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; top: 12px; right: 13px; font-size: 0.6rem; color: var(--accent); opacity: 0.8; }
.home-cta-feature.feat-star::before { content: '\f005'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; top: 12px; right: 13px; font-size: 0.6rem; color: var(--accent); opacity: 0.8; }
-/* Owner / community panel */
-.cta-owner {
- position: relative; overflow: hidden; text-align: left;
- display: flex; gap: 18px; align-items: center;
- margin: 6px 0 26px; padding: 20px 22px; border-radius: 16px;
- background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 14%, transparent), rgba(18,16,14,0.6));
- border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
-}
-.cta-owner-shine { position: absolute; inset: 0; background: linear-gradient(115deg, transparent 35%, rgba(255,255,255,0.08) 50%, transparent 65%); transform: translateX(-120%); animation: ctaSweep 5.5s ease-in-out infinite; pointer-events: none; }
-@keyframes ctaSweep { 0% { transform: translateX(-120%); } 55%,100% { transform: translateX(120%); } }
-.cta-owner-avatar { flex-shrink: 0; width: 58px; height: 58px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: #0e0e12; background: linear-gradient(135deg, var(--accent-light), var(--accent)); box-shadow: 0 8px 22px color-mix(in srgb, var(--accent) 40%, transparent); }
-.cta-owner-body { position: relative; z-index: 1; }
-.cta-owner-body strong { display: block; font-size: 1.1rem; color: var(--text-primary); margin-bottom: 4px; }
-.cta-owner-body p { margin: 0; font-size: 0.9rem; color: var(--text-secondary); line-height: 1.65; }
-.cta-owner-body b { color: var(--text-primary); }
-.cta-owner-body em { color: var(--accent-light); font-style: normal; font-weight: 700; }
-.cta-heart { color: #ff5a7a; animation: ctaHeart 1.4s ease-in-out infinite; display: inline-block; }
-@keyframes ctaHeart { 0%,100% { transform: scale(1); } 25% { transform: scale(1.25); } 40% { transform: scale(1); } }
-
/* Buttons */
/* Buttons */
.home-cta-buttons { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.home-cta-buttons { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
-.cta-btn-glow { position: relative; box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); animation: ctaBtnGlow 2.6s ease-in-out infinite; }
-@keyframes ctaBtnGlow { 0%,100% { box-shadow: 0 8px 22px color-mix(in srgb, var(--accent-dark) 35%, transparent), 0 0 0 0 transparent; } 50% { box-shadow: 0 8px 30px color-mix(in srgb, var(--accent-dark) 55%, transparent), 0 0 22px 2px color-mix(in srgb, var(--accent) 25%, transparent); } }
+/* The glow used to animate box-shadow, which repaints the button every frame for as long as it is
+ on screen. The shadow is now static and the pulse is an opacity fade on a pseudo-element halo. */
+.cta-btn-glow { position: relative; isolation: isolate; box-shadow: 0 8px 22px color-mix(in srgb, var(--accent-dark) 35%, transparent); }
+.cta-btn-glow::after {
+ /* No spread and no outset: with either, the halo's edge reads as a hard ring around the button
+ at the top of the pulse instead of a glow. */
+ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;
+ box-shadow: 0 10px 34px color-mix(in srgb, var(--accent-dark) 50%, transparent), 0 0 30px color-mix(in srgb, var(--accent) 28%, transparent);
+ opacity: 0; animation: ctaBtnGlow 2.6s ease-in-out infinite;
+}
+@keyframes ctaBtnGlow { 0%,100% { opacity: 0; } 50% { opacity: 1; } }
@media (max-width: 860px) {
@media (max-width: 860px) {
.cta-methods { grid-template-columns: 1fr; }
.cta-methods { grid-template-columns: 1fr; }
@@ -2702,14 +2670,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2702,14 +2670,12 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
}
}
@media (max-width: 560px) {
@media (max-width: 560px) {
.home-cta-features { grid-template-columns: 1fr; }
.home-cta-features { grid-template-columns: 1fr; }
- .cta-robot, .cta-owner { flex-direction: column; text-align: center; }
- .cta-robot-body, .cta-owner-body { text-align: center; }
.cta-hero { font-size: 1.7rem; }
.cta-hero { font-size: 1.7rem; }
}
}
/* Respect reduced-motion */
/* Respect reduced-motion */
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.home-cta-banner::before, .cta-ambient, .cta-embers span, .cta-live-dot, .cta-hero-grad,
.home-cta-banner::before, .cta-ambient, .cta-embers span, .cta-live-dot, .cta-hero-grad,
- .cta-robot-glow, .cta-robot-icon, .cta-owner-shine, .cta-heart, .cta-btn-glow, .home-cta-feature {
+ .cta-btn-glow, .home-cta-feature {
animation: none !important;
animation: none !important;
}
}
.cta-hero-grad { -webkit-text-fill-color: var(--accent); color: var(--accent); }
.cta-hero-grad { -webkit-text-fill-color: var(--accent); color: var(--accent); }
@@ -15716,3 +15682,113 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15716,3 +15682,113 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.user-dropdown.show, .user-dropdown.show .ud-group a { animation: none !important; }
.user-dropdown.show, .user-dropdown.show .ud-group a { animation: none !important; }
.user-dropdown .ud-group a:hover { transform: none; }
.user-dropdown .ud-group a:hover { transform: none; }
}
}
+
+/* ═══════════════════════════════════════════════════════════════
+ About section ("Streaming the way it should be") — layout pass.
+
+ Measured before: 1,875px tall on desktop and 4,173px on a phone, roughly five screens. Much of
+ that was repetition — a robot callout restating the starred robots card, an owner callout
+ restating the lede, a tools button restating the tools card — which is gone from the markup.
+ The rest was card shape: on a phone each method card was ~470px, an icon stacked above a title
+ stacked above a paragraph above a tag. Phones now get compact rows with the icon beside the
+ text, which is how a list of short items reads on a narrow screen anyway.
+ ═══════════════════════════════════════════════════════════════ */
+
+.cta-btn-short { display: none; }
+
+/* Method cards: a column, so the tag always lands on the bottom edge. It used to sit inline after
+ the last word of the description in two cards and on its own line in the third. */
+.cta-method { display: flex; flex-direction: column; }
+.cta-method > span { display: block; }
+.cta-method-tag { display: block; width: fit-content; margin-top: auto; padding-top: 3px; }
+.cta-method > span + .cta-method-tag { margin-top: 12px; }
+.cta-method-icon { transition: transform .25s cubic-bezier(.2, 1.3, .3, 1), box-shadow .25s ease; }
+.cta-method:hover .cta-method-icon { transform: rotate(-6deg) scale(1.08); box-shadow: 0 10px 24px color-mix(in srgb, var(--accent-dark) 55%, transparent); }
+.cta-method:hover .cta-method-num { color: color-mix(in srgb, var(--accent) 32%, transparent); }
+.cta-method-num { transition: color .2s ease; }
+
+/* Feature cards: the icon gets the same rounded tile the rest of the site uses for section icons,
+ instead of a bare glyph floating above the title. */
+.home-cta-feature > i:first-child {
+ width: 34px; height: 34px; flex: none; border-radius: 10px;
+ display: grid; place-items: center; font-size: 0.92rem; margin-bottom: 6px;
+ color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 13%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
+ transition: transform .25s cubic-bezier(.2, 1.3, .3, 1), background .2s ease;
+}
+.home-cta-feature:hover > i:first-child { transform: scale(1.1) rotate(-5deg); background: color-mix(in srgb, var(--accent) 22%, transparent); }
+
+/* A sheen that crosses a card once on hover — transform only, clipped to the card. */
+.cta-method::before, .home-cta-feature::after {
+ content: ''; position: absolute; inset: 0; pointer-events: none; border-radius: inherit;
+ background: linear-gradient(105deg, transparent 40%, rgba(255,255,255,0.06) 50%, transparent 60%);
+ transform: translateX(-100%); opacity: 0;
+}
+.home-cta-feature { overflow: hidden; }
+.cta-method:hover::before, .home-cta-feature:hover::after { opacity: 1; animation: ctaCardSheen .8s ease-out; }
+@keyframes ctaCardSheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
+
+/* Money section: a sentence and three short points, not another wall of feature cards. The lede
+ was the one centred paragraph in a section that is otherwise left-aligned. */
+.cta-money-lede {
+ max-width: 700px; margin: 0 0 14px !important; text-align: left;
+ padding: 2px 0 2px 14px; border-left: 3px solid color-mix(in srgb, #4ade80 55%, transparent);
+ font-size: 0.95rem; line-height: 1.6;
+}
+.cta-money-grid .home-cta-feature > i:first-child {
+ color: #4ade80; background: color-mix(in srgb, #4ade80 12%, transparent); border-color: color-mix(in srgb, #4ade80 26%, transparent);
+}
+.cta-money-grid { margin-bottom: 4px !important; }
+
+.home-cta-buttons { margin: 22px 0 6px; }
+
+/* Cards arrive when the section is opened. They used to play a rise animation at page load,
+ while the body was collapsed to zero height — an animation nobody could see. */
+.home-cta-feature { animation: none; }
+.home-cta-banner.about-opening .cta-method,
+.home-cta-banner.about-opening .home-cta-feature,
+.home-cta-banner.about-opening .cta-section-label,
+.home-cta-banner.about-opening .home-cta-buttons,
+.home-cta-banner.about-opening .cta-money-lede {
+ animation: ctaRise .5s cubic-bezier(.22, 1, .36, 1) both;
+ animation-delay: calc(var(--about-i, 0) * 38ms);
+}
+
+@media (max-width: 640px) {
+ /* Methods and features become compact rows: icon beside the text. */
+ .cta-methods { gap: 9px; }
+ .cta-method {
+ display: grid; grid-template-columns: auto 1fr; column-gap: 12px; row-gap: 3px;
+ padding: 13px 14px; align-items: start;
+ }
+ .cta-method-icon { grid-row: 1 / span 3; width: 38px; height: 38px; font-size: 1rem; margin: 0; border-radius: 10px; }
+ .cta-method-num { top: 9px; right: 11px; font-size: 1rem; }
+ .cta-method strong { font-size: 0.93rem; margin: 0; padding-right: 26px; }
+ .cta-method > span { font-size: 0.8rem; line-height: 1.45; }
+ .cta-method-tag, .cta-method > span + .cta-method-tag { margin-top: 5px; }
+
+ .home-cta-features { gap: 8px !important; }
+ .home-cta-feature {
+ display: grid; grid-template-columns: auto 1fr; column-gap: 12px; row-gap: 2px;
+ padding: 12px 13px !important; align-items: start;
+ }
+ .home-cta-feature > i:first-child { grid-row: 1 / span 2; margin: 0; width: 34px; height: 34px; }
+ .home-cta-feature > strong { font-size: 0.88rem; padding-right: 14px; }
+ .home-cta-feature > span { font-size: 0.78rem; line-height: 1.45; }
+ .home-cta-feature.feat-star::before { top: 10px; right: 11px; }
+
+ .home-cta-buttons { gap: 8px; }
+ .home-cta-buttons > .btn { flex: 1 1 140px; justify-content: center; padding-left: 12px; padding-right: 12px; white-space: nowrap; }
+ .cta-btn-long { display: none; }
+ .cta-btn-short { display: inline; }
+ .cta-money-lede { font-size: 0.86rem; padding-left: 11px; }
+ .cta-section-label { white-space: nowrap; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cta-method:hover::before, .home-cta-feature:hover::after { animation: none; opacity: 0; }
+ .home-cta-banner.about-opening * { animation: none !important; }
+ .cta-method:hover .cta-method-icon, .home-cta-feature:hover > i:first-child { transform: none; }
+}
+
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
- <link rel="stylesheet" href="/css/style.css?v=208">
+ <link rel="stylesheet" href="/css/style.css?v=211">
<link rel="stylesheet" href="/css/home-fx.css?v=14">
<link rel="stylesheet" href="/css/home-fx.css?v=14">
<link rel="stylesheet" href="/css/home-tour.css?v=10">
<link rel="stylesheet" href="/css/home-tour.css?v=10">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
@@ -444,7 +444,7 @@
@@ -444,7 +444,7 @@
<div class="home-about-body" id="home-about-body">
<div class="home-about-body" id="home-about-body">
<!-- Three ways to go live -->
<!-- Three ways to go live -->
- <div class="cta-section-label" style="--sc:#60a5fa"><i class="fa-solid fa-tower-broadcast"></i> Three ways to go live — pick your vibe</div>
+ <div class="cta-section-label" style="--sc:#60a5fa"><i class="fa-solid fa-tower-broadcast"></i> Three ways to go live</div>
<div class="cta-methods">
<div class="cta-methods">
<div class="cta-method">
<div class="cta-method">
<div class="cta-method-num">01</div>
<div class="cta-method-num">01</div>
@@ -469,23 +469,13 @@
@@ -469,23 +469,13 @@
</div>
</div>
</div>
</div>
- <!-- Killer feature: real-time hardware control -->
- <div class="cta-robot">
- <div class="cta-robot-glow" aria-hidden="true"></div>
- <div class="cta-robot-icon"><i class="fa-solid fa-robot"></i></div>
- <div class="cta-robot-body">
- <strong>Viewers control real hardware — live.</strong>
- <p>That same sub-second WebRTC pipeline isn't just for talking heads. Viewers can <b>drive robots, pan cameras, and operate interactive rigs in real time</b> — latency low enough to actually <em>play</em>. Ultra-low-latency streaming built for robotics, PTZ cameras, and interactive apps. Good luck doing <i>that</i> on Twitch.</p>
- </div>
- </div>
-
<!-- Differentiators -->
<!-- Differentiators -->
- <div class="cta-section-label cta-diff-label" style="--sc:#fbbf24"><i class="fa-solid fa-crown"></i> Stuff you won't find on Twitch, Kick or YouTube</div>
+ <div class="cta-section-label cta-diff-label" style="--sc:#fbbf24"><i class="fa-solid fa-crown"></i> Only on OpenVibe</div>
<div class="home-cta-features">
<div class="home-cta-features">
<div class="home-cta-feature feat-star">
<div class="home-cta-feature feat-star">
<i class="fa-solid fa-robot"></i>
<i class="fa-solid fa-robot"></i>
<strong>Viewer-controlled robots</strong>
<strong>Viewer-controlled robots</strong>
- <span>Hand your chat the controls — real-time WebRTC command of robots, cameras & hardware. Genuinely nobody else does this.</span>
+ <span>Hand chat the controls: robots, PTZ cameras and interactive rigs over the same sub-second WebRTC pipeline — low enough latency to actually play.</span>
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-tower-broadcast"></i>
<i class="fa-solid fa-tower-broadcast"></i>
@@ -494,8 +484,8 @@
@@ -494,8 +484,8 @@
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-gamepad"></i>
<i class="fa-solid fa-gamepad"></i>
- <strong>A game built into the site</strong>
- <span>Viewers play Scraplandia together, live on-stream, with leaderboards and a shared canvas. Chat that actually does something.</span>
+ <strong>A game next door</strong>
+ <span>Scraplandia lives on OpenVibe.Games — same account, leaderboards and a shared canvas, one click from any stream.</span>
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-earth-americas"></i>
<i class="fa-solid fa-earth-americas"></i>
@@ -525,36 +515,23 @@
@@ -525,36 +515,23 @@
<div class="home-cta-feature feat-star">
<div class="home-cta-feature feat-star">
<i class="fa-solid fa-code"></i>
<i class="fa-solid fa-code"></i>
<strong>100% open source</strong>
<strong>100% open source</strong>
- <span>Every line is public on GitHub. No black boxes, no hidden trackers, no selling your data to anyone.</span>
- </div>
- </div>
-
- <!-- Owner / community -->
- <div class="cta-owner">
- <div class="cta-owner-shine" aria-hidden="true"></div>
- <div class="cta-owner-avatar"><i class="fa-solid fa-mug-hot"></i></div>
- <div class="cta-owner-body">
- <strong>Run by a human, not a boardroom.</strong>
- <p>OpenVibe.Live is built & maintained by one hobbyist programmer who's usually <b>right here in chat</b>. Laid-back rules, no corporate nonsense, and a real person who <em>actually wants your feedback</em>. Got an idea? Say it in a stream — features here get shipped because a <b>user asked for them</b>. You help shape OpenVibe.Live and the whole OpenVibe. <span class="cta-heart">♥</span></p>
+ <span>Every line is public on GitHub. Read it, fork it, or send a pull request — features here get built because someone asked.</span>
</div>
</div>
</div>
</div>
<div class="home-cta-buttons">
<div class="home-cta-buttons">
- <button class="btn btn-primary btn-lg cta-btn-glow" onclick="currentUser ? navigate('/broadcast') : showModal('register')">
- <i class="fa-solid fa-broadcast-tower"></i> Launch your stream
+ <button class="btn btn-primary btn-lg cta-btn-glow" onclick="if (window.startGoLiveJourney) startGoLiveJourney(); else if (currentUser) navigate('/broadcast'); else showModal('register');">
+ <i class="fa-solid fa-broadcast-tower"></i> <span class="cta-btn-long">Launch your stream</span><span class="cta-btn-short">Go live</span>
</button>
</button>
<a href="/chat" class="btn btn-outline btn-lg" onclick="return handleLinkClick(event, '/chat')">
<a href="/chat" class="btn btn-outline btn-lg" onclick="return handleLinkClick(event, '/chat')">
<i class="fa-solid fa-comments"></i> Say hi in chat
<i class="fa-solid fa-comments"></i> Say hi in chat
</a>
</a>
- <a href="https://openvibe.network" class="btn btn-outline btn-lg">
- <i class="fa-solid fa-screwdriver-wrench"></i> 200+ free tools
- </a>
</div>
</div>
<!-- Where the money goes — part of the same About section -->
<!-- Where the money goes — part of the same About section -->
<div class="cta-section-label cta-diff-label" style="--sc:#4ade80"><i class="fa-solid fa-seedling"></i> Where the money goes</div>
<div class="cta-section-label cta-diff-label" style="--sc:#4ade80"><i class="fa-solid fa-seedling"></i> Where the money goes</div>
- <p class="cta-lede">Open source, run in the open, and built to stay that way. Whatever the network earns goes back into it — keeping the servers on, paying for the storage and bandwidth that free streaming actually costs, funding new features, and putting money into the creators and events that make this place worth showing up to.</p>
- <div class="home-cta-features">
+ <p class="cta-lede cta-money-lede">Whatever the network earns goes back into it — the servers, the storage and bandwidth that free streaming really costs, new features, and the creators and events that make this place worth showing up to.</p>
+ <div class="home-cta-features cta-money-grid">
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-plug-circle-bolt"></i>
<i class="fa-solid fa-plug-circle-bolt"></i>
<strong>Keep it running</strong>
<strong>Keep it running</strong>
@@ -567,8 +544,8 @@
@@ -567,8 +544,8 @@
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-code-branch"></i>
<i class="fa-solid fa-code-branch"></i>
- <strong>Stay open, stay decent</strong>
- <span>Every line is open source and the analytics are public. No selling the community out, no surprise pivot, no dark patterns — the code is right there if you want to check.</span>
+ <strong>No surprise pivot</strong>
+ <span>Where it goes stays visible. No selling the community out, no bait-and-switch, no dark patterns.</span>
</div>
</div>
</div>
</div>
</div><!-- /home-about-body -->
</div><!-- /home-about-body -->
@@ -614,14 +591,14 @@
@@ -614,14 +591,14 @@
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<!-- Scraplandia Leaderboards -->
<!-- Scraplandia Leaderboards -->
- <div class="section-header" style="--sc:#fbbf24" id="home-quest-header" style="display:none">
+ <div class="section-header" style="--sc:#fbbf24;display:none" id="home-quest-header">
<h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards<small class="section-kicker">the ladder</small></h2>
<h2><i class="fa-solid fa-trophy"></i> Scraplandia Leaderboards<small class="section-kicker">the ladder</small></h2>
<a href="https://openvibe.games/game" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Play Game</a>
<a href="https://openvibe.games/game" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Play Game</a>
</div>
</div>
<div class="home-leaderboards" id="home-leaderboards"></div>
<div class="home-leaderboards" id="home-leaderboards"></div>
<!-- Canvas Preview -->
<!-- Canvas Preview -->
- <div class="section-header" style="--sc:#4ade80" id="home-canvas-header" style="display:none">
+ <div class="section-header" style="--sc:#4ade80;display:none" id="home-canvas-header">
<h2><i class="fa-solid fa-palette"></i> Community Canvas<small class="section-kicker">drawn together</small></h2>
<h2><i class="fa-solid fa-palette"></i> Community Canvas<small class="section-kicker">drawn together</small></h2>
<a href="https://openvibe.games/canvas" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Open Canvas</a>
<a href="https://openvibe.games/canvas" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">Open Canvas</a>
</div>
</div>
@@ -5160,7 +5137,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5160,7 +5137,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
};
};
})();
})();
</script>
</script>
-<script src="/js/app.js?v=176" defer></script>
+<script src="/js/app.js?v=178" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -2335,17 +2335,37 @@ function _initHomeAbout() {
@@ -2335,17 +2335,37 @@ function _initHomeAbout() {
}
}
} catch { /* observer optional */ }
} catch { /* observer optional */ }
}
}
+/**
+ * Stagger the About cards in as the section opens.
+ *
+ * Each animated child gets an index so they arrive in reading order; the class comes off on a
+ * timer so a card whose animation never ran is not left holding its from-state.
+ */
+function _aboutPlayOpening(banner) {
+ let reduce = false;
+ try { reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { /* */ }
+ if (reduce) return;
+ const items = banner.querySelectorAll('.home-about-body .cta-section-label, .home-about-body .cta-method, .home-about-body .home-cta-feature, .home-about-body .home-cta-buttons, .home-about-body .cta-money-lede');
+ items.forEach((el, i) => el.style.setProperty('--about-i', String(Math.min(i, 16))));
+ banner.classList.remove('about-opening'); void banner.offsetWidth;
+ banner.classList.add('about-opening');
+ clearTimeout(banner._aboutTimer);
+ banner._aboutTimer = setTimeout(() => banner.classList.remove('about-opening'), 1400);
+}
function toggleHomeAbout() {
function toggleHomeAbout() {
const banner = document.getElementById('home-cta-banner');
const banner = document.getElementById('home-cta-banner');
if (!banner) return;
if (!banner) return;
const collapsed = banner.classList.toggle('about-collapsed');
const collapsed = banner.classList.toggle('about-collapsed');
const toggle = banner.querySelector('.home-about-toggle');
const toggle = banner.querySelector('.home-about-toggle');
if (toggle) toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (toggle) toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
+ if (!collapsed) _aboutPlayOpening(banner);
}
}
function openHomeAbout() {
function openHomeAbout() {
const banner = document.getElementById('home-cta-banner');
const banner = document.getElementById('home-cta-banner');
if (!banner) return;
if (!banner) return;
+ const wasCollapsed = banner.classList.contains('about-collapsed');
banner.classList.remove('about-collapsed');
banner.classList.remove('about-collapsed');
+ if (wasCollapsed) _aboutPlayOpening(banner);
const toggle = banner.querySelector('.home-about-toggle');
const toggle = banner.querySelector('.home-about-toggle');
if (toggle) toggle.setAttribute('aria-expanded', 'true');
if (toggle) toggle.setAttribute('aria-expanded', 'true');
banner.scrollIntoView({ behavior: 'smooth', block: 'start' });
banner.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -2908,9 +2928,11 @@ async function loadHomeLeaderboards() {
@@ -2908,9 +2928,11 @@ async function loadHomeLeaderboards() {
try {
try {
const boards = ['total_level', 'combat', 'mining', 'fishing'];
const boards = ['total_level', 'combat', 'mining', 'fishing'];
const questUrl = getScraplandiaUrl();
const questUrl = getScraplandiaUrl();
- const results = await Promise.all(boards.map(b =>
- fetch(`${questUrl}/api/game/leaderboard/${b}`).then(r => r.json()).catch(() => ({ entries: [] }))
- ));
+ // One probe first: if the service is not answering, the other three would fail the same way.
+ const first = await fetchServiceJson(`${questUrl}/api/game/leaderboard/${boards[0]}`);
+ const rest = first ? await Promise.all(boards.slice(1).map(b => fetchServiceJson(`${questUrl}/api/game/leaderboard/${b}`))) : [];
+ const results = [first, ...rest].map(r => r || { entries: [] });
+ while (results.length < boards.length) results.push({ entries: [] });
const header = document.getElementById('home-quest-header');
const header = document.getElementById('home-quest-header');
const container = document.getElementById('home-leaderboards');
const container = document.getElementById('home-leaderboards');
const hasData = results.some(r => r.entries && r.entries.length);
const hasData = results.some(r => r.entries && r.entries.length);
@@ -2939,11 +2961,40 @@ async function loadHomeLeaderboards() {
@@ -2939,11 +2961,40 @@ async function loadHomeLeaderboards() {
} catch { /* silent */ }
} catch { /* silent */ }
}
}
+/**
+ * Fetch JSON from another OpenVibe service, remembering when it is not there.
+ *
+ * The home page asks OpenVibe.Games for leaderboards and canvas state. Those endpoints currently
+ * answer with the Games SPA's HTML and no CORS headers, so from openvibe.live all five requests
+ * fail — on every single home page view. Failing is fine; failing five times per visit forever is
+ * not. A miss is remembered for a few hours per browser, so the page stops asking a service that
+ * is not answering and starts again on its own once the window lapses. A response that is not
+ * JSON counts as a miss instead of throwing inside .json().
+ */
+const _XSVC_MISS_KEY = 'ov_xsvc_miss_v1';
+const _XSVC_MISS_MS = 6 * 60 * 60 * 1000;
+function _xsvcMisses() { try { return JSON.parse(localStorage.getItem(_XSVC_MISS_KEY) || '{}'); } catch { return {}; } }
+async function fetchServiceJson(url) {
+ let origin = url;
+ try { origin = new URL(url).origin + new URL(url).pathname.split('/').slice(0, 3).join('/'); } catch { /* */ }
+ const misses = _xsvcMisses();
+ if (misses[origin] && Date.now() - misses[origin] < _XSVC_MISS_MS) return null;
+ try {
+ const r = await fetch(url, { credentials: 'omit' });
+ const type = r.headers.get('content-type') || '';
+ if (!r.ok || !/json/i.test(type)) throw new Error('not json');
+ return await r.json();
+ } catch {
+ try { misses[origin] = Date.now(); localStorage.setItem(_XSVC_MISS_KEY, JSON.stringify(misses)); } catch { /* */ }
+ return null;
+ }
+}
+
async function loadHomeCanvas() {
async function loadHomeCanvas() {
try {
try {
- const data = await fetch(`${getScraplandiaUrl()}/api/game/canvas/state`).then(r => r.json());
const header = document.getElementById('home-canvas-header');
const header = document.getElementById('home-canvas-header');
const container = document.getElementById('home-canvas-preview');
const container = document.getElementById('home-canvas-preview');
+ const data = await fetchServiceJson(`${getScraplandiaUrl()}/api/game/canvas/state`);
if (!data || !data.board) { if (header) header.style.display = 'none'; return; }
if (!data || !data.board) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
if (header) header.style.display = '';
@@ -2983,7 +3034,10 @@ async function loadHomeCanvas() {
@@ -2983,7 +3034,10 @@ async function loadHomeCanvas() {
ctx.fillRect(tile.x * scale, tile.y * scale, scale, scale);
ctx.fillRect(tile.x * scale, tile.y * scale, scale, scale);
}
}
}
}
- } catch { /* silent */ }
+ } catch {
+ const header = document.getElementById('home-canvas-header');
+ if (header) header.style.display = 'none';
+ }
}
}
// Markup for a single stream card (live or recent). Extracted so the home page
// Markup for a single stream card (live or recent). Extracted so the home page
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+/**
+ * Duplicate attributes on one element.
+ *
+ * An HTML parser keeps the first occurrence of an attribute and silently drops the rest, so
+ * `<div style="--sc:#fff" style="display:none">` renders visible. This has shipped three times on
+ * the home page — the changelog header lost its margin, and the canvas and leaderboard headers lost
+ * `display:none` and showed as titles over empty sections whenever their loaders failed. Nothing in
+ * a browser reports it, so it is checked here.
+ *
+ * node test/html-attributes.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const PUBLIC = path.join(__dirname, '..', 'public');
+const files = fs.readdirSync(PUBLIC).filter(f => f.endsWith('.html'));
+let problems = [];
+
+for (const f of files) {
+ const src = fs.readFileSync(path.join(PUBLIC, f), 'utf8');
+ // Strip comments, scripts and styles so their contents are not read as tags.
+ const clean = src
+ .replace(/<!--[\s\S]*?-->/g, m => m.replace(/[^\n]/g, ' '))
+ .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, m => m.replace(/[^\n]/g, ' '));
+ const tagRe = /<([a-zA-Z][\w-]*)(\s[^<>]*?)?\/?>/g;
+ let m;
+ while ((m = tagRe.exec(clean))) {
+ const attrs = m[2] || '';
+ // Remove quoted values before collecting names, so "a=b" inside a value is not a name.
+ const bare = attrs.replace(/"[^"]*"|'[^']*'/g, '""');
+ const names = [...bare.matchAll(/([^\s"'=<>\/]+)\s*(?==|\s|$)/g)].map(x => x[1].toLowerCase());
+ const seen = new Set();
+ for (const n of names) {
+ if (seen.has(n)) {
+ const line = clean.slice(0, m.index).split('\n').length;
+ problems.push(`${f}:${line} <${m[1]}> has "${n}" twice`);
+ break;
+ }
+ seen.add(n);
+ }
+ }
+}
+
+assert.deepStrictEqual(problems, [], 'duplicate attributes found:\n ' + problems.join('\n '));
+console.log(` ok - no duplicate attributes across ${files.length} HTML files`);