openvibers/openvibe.live Commits

About section: cut the repetition, fix the phone layout, and stop two dead cross-site requests

OP OpenVibers committed 16 Sept 2026 · 70d8e35b9d · 4 files, +246 −92

Measured with the section open: 4,173px tall on a phone and 1,875px on desktop — about five
screens on a phone. Now 2,680px and 1,596px.

Much of the height was saying things twice, which is the thing asked to go everywhere else too:
- "Viewers control real hardware" restated the starred "Viewer-controlled robots" card directly
  under it. The callout is gone; its one detail the card lacked (PTZ cameras and rigs, latency
  low enough to play) moved into the card.
- "Run by a human, not a boardroom" restated the lede two screens up — one person, in chat,
  shipping your ideas. Gone.
- "200+ free tools" was a card and a button, and the button pointed at openvibe.network rather
  than openvibe.tools. The button is gone.

Two claims were out of date. "No hidden trackers" is exactly the kind of promise that was asked
to come out; the open-source card now says read it, fork it, send a PR. "A game built into the
site" stopped being true when Scraplandia moved to OpenVibe.Games, and now says so. The money
section's lede and third card both restated "open source" again and now stay on the subject.

Layout: on a phone each method card was ~470px — icon over title over paragraph over tag — and
each feature card similar. Both are compact rows with the icon beside the text. Method tags sat
inline after the last word in two cards and on their own line in the third; they now always land
on the card's bottom edge. Section labels wrapped onto two lines with the rule left as a stub, so
they are shorter. Feature icons get the same rounded tile the section headers use. The money lede
was the one centred paragraph in a left-aligned section. The launch button says "Go live" where
"Launch your stream" would wrap, and goes through startGoLiveJourney like every other go-live
button.

Effects: cards stagger in when the section is opened — they used to play a rise animation at page
load, while the body was collapsed to zero height and nobody could see it. Icons tilt and a sheen
crosses a card on hover. The launch button's pulsing glow animated box-shadow, a repaint every
frame; it is now an opacity fade on a pseudo-element halo, and its edge no longer reads as a hard
ring. The dead robot and owner CSS and keyframes went with their markup.

Also fixes a bug found while checking the phone layout: the Community Canvas and Scraplandia
Leaderboards headers each had two style attributes. The parser keeps the first and drops the
second, so `display:none` never applied. OpenVibe.Games answers /api/game/* with its SPA's HTML
and no CORS, so every home view fired five failing requests, and the canvas loader — no per-
request catch, and a catch that never hid the header — left "Community Canvas" titled over an
empty section on every visit. Both headers now start hidden, both loaders hide on any failure, a
non-JSON response counts as a miss instead of throwing in .json(), and a miss is remembered for
six hours per browser so the page stops asking a service that is not answering. The leaderboards
probe one board before requesting the other three.

test/html-attributes.test.js checks every public HTML file for duplicate attributes — this was the
third instance, and no browser reports it. Confirmed failing with the canvas header reverted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex cc1bd4f5..08c7d34c 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; }
++}
++
ddiff --git a/public/index.html b/public/index.html
iindex d6798f8c..fbd2e601 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,7 +88,7 @@
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. -->
<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 &amp; 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 &amp; 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 &mdash; 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 &mdash; 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 &mdash; 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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex e99e566f..cde0039f 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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 page
ddiff --git a/test/html-attributes.test.js b/test/html-attributes.test.js
nnew file mode 100644
iindex 00000000..c6a9791c
---- /dev/null
++++ b/test/html-attributes.test.js
@@@ -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`);