openvibers/openvibe.live Commits

Each page carries only its own markup and one <h1>: the home page, Updates and the Rundown are fragments inlined on their own route, so other pages lose the home hero's 37 KB and its <h1> (#343)

OP OpenVibers committed 20 days ago · d8b415911c · 12 files, +585 −535

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 7de5f9d4..f4c06ab4 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -30,7 +30,7 @@ BASE=http://127.0.0.1:3000 npm run test:browser # Browser smoke (running server
- **Database:** [server/db/database.js](server/db/database.js) — all queries (async, `openvibe-sdk/db`), schema in [migrations/](migrations/) (`0001_analytics.sql`, `0002_live.sql`)
- **Auth:** [server/auth/auth.js](server/auth/auth.js) — RS256 JWT from openvibe.tools SSO + `hbt_` API tokens
- **Permissions:** [server/auth/permissions.js](server/auth/permissions.js) — role hierarchy: `user < streamer < global_mod < admin`
--- **Frontend shell:** [public/index.html](public/index.html) — navbar, home page and empty `<section id="page-*">` shells; routing via `history.pushState` in [public/js/app.js](public/js/app.js)
++- **Frontend shell:** [public/index.html](public/index.html) — navbar and `<section id="page-*">` shells (page markup, the home page's included, is in `public/fragments/`, inlined on its own route); routing via `history.pushState` in [public/js/app.js](public/js/app.js)
- **Route loading:** [public/features.json](public/features.json) maps routes → features (fragment in `public/fragments/`, CSS in `public/css/features/`, scripts, deps, stubs); [public/js/ov-loader.js](public/js/ov-loader.js) loads them. New page code goes in a feature, not in `index.html`/core `app.js`. See [docs/architecture.md](docs/architecture.md#frontend-loading).
Each feature lives in its own `server/<feature>/` directory with `routes.js` + service files. Frontend: one JS file per feature in `public/js/`, registered in `public/features.json`.
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex ddaa2f2e..8ae39b18 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -39,10 +39,11 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
The SPA is plain JavaScript with no build step. What loads is decided per route:
```
--public/index.html shell: navbar, home page, empty <section id="page-*"> shells, core scripts
++public/index.html shell: navbar, empty <section id="page-*"> shells, core scripts (no page markup)
public/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch
routes: URL pattern → features
--public/fragments/*.html page markup for channel, dashboard, broadcast, chat, documentation, content, moments (VOD and clip pages are OpenVibe.Video's)
++public/fragments/*.html page markup for home, channel, dashboard, broadcast, chat, documentation, content, moments, search,
++ rundown, updates (VOD and clip pages are OpenVibe.Video's)
public/css/features/*.css rules used only by one feature (split out of style.css / broadcast.css / i18n-star.css)
public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ov.load(feature), ov.route(path), ov.prefetch(),
route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks()
@@@ -52,7 +53,9 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
- **First paint.** `server/web/assets.js` renders `index.html` for the requested path: the route's
stylesheets go in `<head>` (right after `style.css`), its scripts after the core scripts, and its
-- fragment is inlined into the section shell. A direct visit needs no extra round trip.
++ fragment is inlined into the section shell. A direct visit needs no extra round trip, and a page
++ carries only its own markup: the home page's hero and lists are inlined on `/` alone, so every
++ other page has one `<h1>`, its own (`test/shell-one-h1.test.js`).
- **Navigation.** `routeFromURL()` calls `whenRouteReady(page, render)`: the loader inserts the
feature's markup first (modules bind to it on load), then dependencies, stylesheets and scripts in
order, runs the feature's `after` hook once, and renders only if the route generation is still
ddiff --git a/public/features.json b/public/features.json
iindex 8abb1e33..dbfc58b2 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -2,6 +2,7 @@
"_comment": "Feature and route registry, read by server/web/assets.js (first paint: which assets the requested route needs) and by public/js/ov-loader.js (navigation: what to load before a route renders). Scripts in a feature run in the listed order, after the feature's dependencies. `fragment` names public/fragments/<name>.html, inserted into section #page-<section> before any of the feature's scripts run. `after` names a global function called once when the feature has loaded. `stubs` are global functions that inline handlers call; until the feature is loaded they load it and then call the real function. See docs/architecture.md#frontend-loading.",
"features": {
"home": {
++ "fragment": "home",
"css": [
"/css/features/home.css",
"/css/home-fx.css",
@@@ -232,7 +233,7 @@
"regenerateDashStreamKey"
]
},
-- "rundown": {"css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},
++ "rundown": {"fragment": "rundown", "css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},
"recap": {
"css": [
"/css/recap.css"
@@@ -249,6 +250,9 @@
"openCosmeticsModal"
]
},
++ "updates": {
++ "fragment": "updates"
++ },
"docs": {
"fragment": "documentation",
"css": [
@@@ -343,6 +347,7 @@
]
},
{"path": "^/rundown", "features": ["rundown"]}, {"path": "^/arena", "features": ["rundown"]},
++ {"path": "^/updates/?$", "features": ["updates"]},
{
"path": "^/documentation",
"features": [
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
nnew file mode 100644
iindex 00000000..3f7d7ee3
---- /dev/null
++++ b/public/fragments/home.html
@@@ -0,0 +1,500 @@
++<div class="hero" data-fx-viewport>
++ <div class="hero-bg" id="hero-bg" aria-hidden="true"><div class="hero-bg-layer"></div><div class="hero-bg-layer"></div></div>
++ <div class="hero-aurora" aria-hidden="true"></div>
++ <div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
++ <div class="hero-scrim" aria-hidden="true"></div>
++ <div class="hero-content">
++ <h1><span class="brand-mark brand-mark--hero" aria-hidden="true"><svg viewBox="0 0 48 48">
++ <defs>
++ <linearGradient id="ovh-g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
++ <linearGradient id="ovh-v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
++ <path id="ovh-ring" d="M24,6 A18,18 0 1,1 23.99,6 Z"/>
++ </defs>
++ <circle class="ov-glow" cx="24" cy="24" r="21"/>
++ <circle class="ov-ring" cx="24" cy="24" r="18"/>
++ <circle class="ov-comet" cx="24" cy="24" r="18" stroke="url(#ovh-g)"/>
++ <path class="ov-v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#ovh-v)"/>
++ <circle class="ov-dot" cx="24" cy="34" r="2.6"/>
++ <circle class="ov-orbit" r="2"><animateMotion dur="3.6s" repeatCount="indefinite" rotate="auto"><mpath href="#ovh-ring"/></animateMotion></circle>
++ <circle class="ov-orbit ov-orbit--tail" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#ovh-ring"/></animateMotion></circle>
++ <circle class="ov-spark" cx="24" cy="24" r="1.3"/><circle class="ov-spark ov-spark--2" cx="24" cy="24" r="1"/><circle class="ov-spark ov-spark--3" cx="24" cy="24" r="1.1"/>
++</svg></span> OpenVibe.Live</h1>
++ <!-- The first word and quip ship in the HTML so the hero is complete at first paint; the
++ rotators take over once the page's data arrives. No aria-live: a slogan that changes
++ every few seconds would be read out to screen-reader users over and over. -->
++ <p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate visible" id="hero-rotate">stealth campers</span></span></p>
++ <p class="hero-quip visible" id="hero-quip">No investors. No suits. Just vibes.</p>
++ <!-- Holds its line from the first paint (the countdown fills it with the page's data), so the buttons below do not move. -->
++ <div class="hero-slogan-timer" id="hero-slogan-timer">&nbsp;</div>
++ <!-- One primary action and its promise, then the other destinations (owner, 2026-10-18: "whole area is kinda
++ messy and spacing is weird"; six equal buttons wrapped 5 + 1 with the no-OBS line floating between
++ rows). The headline feature stays in the first screen: going live from a browser with no OBS and
++ no follower minimum is what people search and ask AI assistants for. -->
++ <div class="hero-cta" data-fx-viewport>
++ <div class="hero-primary">
++ <button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Go live <small>in your browser</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
++ <p class="hero-golive-note"><span><i class="fa-solid fa-check" aria-hidden="true"></i> No OBS</span><span><i class="fa-solid fa-check" aria-hidden="true"></i> No downloads</span><span><i class="fa-solid fa-check" aria-hidden="true"></i> No follower minimum</span><a href="/docs/go-live-in-your-browser">How it works <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></p>
++ </div>
++ <nav class="hero-links" aria-label="More from OpenVibe">
++ <a href="https://openvibe.tools" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="https://openvibe.games" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:3" class="hb hb-vibe"
++ data-ovtip-title="Mod it, bot it, build on it"
++ data-ovtip-icon="fa-wand-magic-sparkles"
++ data-ovtip-accent="#f472b6"
++ data-ovtip-place="top"
++ data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games &mdash; every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
++ data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"
++ data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">&amp; Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:4" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:5" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
++ </nav>
++ </div>
++ <!-- Signed-in streamers with setup left to do see their next task here, in the row of
++ things to do rather than a screen below (public/js/guides-live.js → setupHeroNextUp). -->
++ <div class="tour-quest-slot hero-quest-slot" id="hero-next-up" data-fx-viewport hidden></div>
++ <div class="hero-philosophy-links">
++ <a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
++ <i class="fa-solid fa-book-open"></i> What is OpenVibe?
++ </a>
++ <span class="hero-philosophy-sep" aria-hidden="true">&bull;</span>
++ <span class="hero-legal"><a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
++ <span aria-hidden="true">&bull;</span>
++ <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
++ <span aria-hidden="true">&bull;</span>
++ <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a></span>
++ </div>
++ <!-- Newest shipped commit — built in the open, receipts included. Sits under the
++ philosophy row so the proof is next to the promise. -->
++ <a class="hero-latest" id="hero-latest" data-ov-shipped="latest" data-service="live" href="/updates" onclick="return handleLinkClick(event, '/updates')"></a>
++ <div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
++ </div>
++</div>
++
++<div class="container">
++ <!-- Featured live stream (public/js/home-featured.js): filled only when someone is live. -->
++ <div class="home-featured" id="home-featured" hidden></div>
++ <!-- Live Streams -->
++ <div class="section-header" style="--sc:#ef4444">
++ <h2><i class="fa-solid fa-circle live-dot"></i> Live Now<small class="section-kicker">on air</small></h2>
++ <span id="live-count" class="badge">0</span>
++ <button type="button" class="home-featured-toggle" id="home-featured-toggle" hidden><i class="fa-solid fa-satellite-dish"></i> Show featured stream</button>
++ </div>
++ <div class="stream-grid" id="stream-grid-live">
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
++ <div class="empty-state" id="no-live-streams" style="display:none">
++ <i class="fa-solid fa-tower-broadcast fa-3x"></i>
++ <p>No one is streaming right now</p>
++ <!-- This was a line of grey text telling people to go live with no way to do it.
++ It is the same button as the one under the tour, routed through the same
++ entry point, so signing in lands you back in the go-live journey. -->
++ <button type="button" class="ovg-firstlive" onclick="if (window.startGoLiveJourney) startGoLiveJourney(); else location.href='/broadcast';">
++ <span class="ovg-firstlive-ring" aria-hidden="true"></span>
++ <span class="ovg-firstlive-sheen" aria-hidden="true"></span>
++ <i class="fa-solid fa-tower-broadcast"></i>
++ <span>Be the first to go live</span>
++ <i class="fa-solid fa-arrow-right ovg-firstlive-go"></i>
++ </button>
++ </div>
++ </div>
++
++ <!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
++ <div class="home-digest" id="home-digest"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
++
++ <!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
++ <div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
++
++ <div class="hero-egg" id="hero-egg" style="display:none">
++ <button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
++ <span class="hero-egg-icon">🥚</span>
++ <span class="hero-egg-titles"><span class="hero-egg-kicker">Today's Secret</span><span class="hero-egg-title" id="hero-egg-title"></span></span>
++ <span class="hero-egg-status" id="hero-egg-status"></span>
++ <i class="fa-solid fa-chevron-down hero-egg-chev"></i>
++ </button>
++ <div class="hero-egg-body">
++ <div class="hero-egg-inner">
++ <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
++ <div class="hero-egg-slots" id="hero-egg-slots" role="group" aria-label="Your key sequence"></div>
++ <ol class="hero-egg-clues" id="hero-egg-clues"></ol>
++ <div class="hero-egg-pad" id="hero-egg-pad">
++ <div class="hero-egg-pad-arrows">
++ <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>
++ <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>
++ <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>
++ <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>
++ <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>
++ <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>
++ </div>
++ <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>
++ </div>
++ <div class="hero-egg-foot">
++ <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>
++ <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>
++ </div>
++ </div>
++ </div>
++ </div>
++
++ <!-- Community pulse: goals near completion, latest activity, weekly leaders -->
++ <div class="home-section-lazy" id="home-pulse-section" style="display:none">
++ <div class="section-header" style="--sc:#f59e0b">
++ <h2><i class="fa-solid fa-bolt"></i> Happening Now<small class="section-kicker">the pulse</small></h2>
++ </div>
++ <div class="pulse-grid" id="pulse-grid"></div>
++ </div>
++
++ <!-- What people made, above the introduction to the site (roadmap 32.7: the home leads with live
++ content and creators): channels that were just on, their recordings, the clips people took
++ and their pastes. What the AI made follows it (AI Moments; 33.6: made, then derived). -->
++ <!-- Recently Online -->
++ <div class="section-header" style="--sc:#60a5fa">
++ <h2><i class="fa-solid fa-clock-rotate-left"></i> Recently Online<small class="section-kicker">just missed them</small></h2>
++ </div>
++ <div class="stream-grid" id="stream-grid-recent">
++ <div class="empty-state">
++ <p class="muted">No recent streams</p>
++ </div>
++ </div>
++ <div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
++
++ <!-- Recent VODs -->
++ <div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
++ <h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
++ <a href="/content?type=vods" onclick="return handleLinkClick(event, '/content?type=vods')" class="section-action">View all</a>
++ </div>
++ <div class="stream-grid" id="home-recent-vods-grid"></div>
++ <div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
++
++ <!-- Recent Clips -->
++ <div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
++ <h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
++ <a href="/content?type=clips" onclick="return handleLinkClick(event, '/content?type=clips')" class="section-action">View all</a>
++ </div>
++ <div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
++ <div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
++
++ <!-- Recent Pastes -->
++ <div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
++ <h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
++ <a href="/content?type=pastes" onclick="return handleLinkClick(event, '/content?type=pastes')" class="section-action">View all</a>
++ </div>
++ <div class="home-pastes-list" id="home-pastes-list"></div>
++ <div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
++
++ <!-- AI Moments showcase -->
++ <div class="home-section-lazy" id="home-moments-section" style="display:none">
++ <div class="section-header" style="--sc:#22d3ee">
++ <h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
++ <a href="/moments" onclick="return handleLinkClick(event, '/moments')" class="section-action">See all</a>
++ </div>
++ <div class="moments-row ov-rail-track" id="home-moments-row"></div>
++ </div>
++
++ <!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
++ why it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->
++ <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
++ <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
++ <!-- Streaming CTA -->
++ <div class="home-cta-banner" id="home-cta-banner" data-fx-viewport>
++ <div class="cta-ambient" aria-hidden="true"></div>
++ <div class="cta-embers" aria-hidden="true">
++ <span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
++ </div>
++ <div class="home-cta-inner">
++
++ <!-- The all-caps eyebrow here ("HOME-GROWN & HOBBYIST-RUN — NOT ANOTHER CORPORATE
++ STREAM SITE") was a louder restatement of the lede two lines below it, which
++ already says the platform is built by one person who streams here. One of them
++ is enough, and the lede is the one that reads like a human wrote it. -->
++ <div class="cta-chips" aria-hidden="true">
++ <span class="cta-chip"><i class="fa-solid fa-people-group"></i> Community-run</span>
++ <span class="cta-chip"><i class="fa-solid fa-comment"></i> Free speech, light rules</span>
++ <span class="cta-chip"><i class="fa-solid fa-code-branch"></i> Built in the open</span>
++ </div>
++ <h2 class="cta-hero">Streaming the way it <span class="cta-hero-grad">should</span> be.</h2>
++ <p class="cta-lede">
++ An <strong>open, community-run</strong> streaming platform, built in public by people who stream and hang out in chat. The ideas from chat are the ones that ship. <strong>Free speech, light-touch moderation, no mystery bans.</strong>
++ </p>
++
++ <!-- Always shown (not behind "See what makes it different"): going live from the browser with
++ no OBS and no follower minimum is what people search and ask AI assistants for. -->
++ <div class="cta-actions">
++ <a class="cta-btn cta-btn-primary" href="/broadcast" onclick="return goLiveNav(event)"><i class="fa-solid fa-bolt" aria-hidden="true"></i> Go live from your browser</a>
++ <a class="cta-btn cta-btn-ghost" href="/docs/go-live-in-your-browser">How it works</a>
++ </div>
++ <ul class="cta-checks" aria-label="What going live needs">
++ <li><i class="fa-solid fa-check" aria-hidden="true"></i> No OBS</li>
++ <li><i class="fa-solid fa-check" aria-hidden="true"></i> No downloads</li>
++ <li><i class="fa-solid fa-check" aria-hidden="true"></i> No follower minimum</li>
++ <li><i class="fa-solid fa-check" aria-hidden="true"></i> Chromebook, laptop or phone</li>
++ </ul>
++
++ <button class="home-about-toggle" type="button" onclick="toggleHomeAbout()" aria-expanded="false" aria-controls="home-about-body">
++ <span class="home-about-toggle-more"><i class="fa-solid fa-circle-plus"></i> See what makes it different</span>
++ <span class="home-about-toggle-less"><i class="fa-solid fa-circle-minus"></i> Show less</span>
++ </button>
++
++ <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</div>
++ <div class="cta-methods">
++ <div class="cta-method">
++ <div class="cta-method-num">01</div>
++ <div class="cta-method-icon"><i class="fa-solid fa-bolt"></i></div>
++ <strong>Right in your browser</strong>
++ <span>One click, zero software. Powered by <b>WebRTC</b> for <em>sub-second, ultra-low latency</em> — the fastest way to go live anywhere.</span>
++ <div class="cta-method-tag">WebRTC · &lt;1s latency</div>
++ </div>
++ <div class="cta-method">
++ <div class="cta-method-num">02</div>
++ <div class="cta-method-icon"><i class="fa-solid fa-sliders"></i></div>
++ <strong>OBS &amp; RTMP</strong>
++ <span>Plug in OBS, Streamlabs, or any RTMP encoder for a full scene-based, pro-grade production workflow.</span>
++ <div class="cta-method-tag">RTMP · pro setups</div>
++ </div>
++ <div class="cta-method">
++ <div class="cta-method-num">03</div>
++ <div class="cta-method-icon"><i class="fa-solid fa-terminal"></i></div>
++ <strong>ffmpeg &amp; CLI</strong>
++ <span>Headless and fully scriptable — stream straight from a Raspberry Pi, a server, or an embedded device.</span>
++ <div class="cta-method-tag">jsmpeg · headless</div>
++ </div>
++ </div>
++
++ <!-- Differentiators -->
++ <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 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>
++ <strong>Restream everywhere at once</strong>
++ <span>Push a single broadcast to Twitch, YouTube, Kick, RobotStreamer &amp; any RTMP server simultaneously — from one dashboard.</span>
++ </div>
++ <div class="home-cta-feature">
++ <i class="fa-solid fa-gamepad"></i>
++ <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>
++ <strong>Global chat + voice</strong>
++ <span>A sitewide chatroom and drop-in voice channels connect everyone across every stream — no Discord required.</span>
++ </div>
++ <div class="home-cta-feature">
++ <i class="fa-solid fa-bullseye"></i>
++ <strong>Donation goals &amp; Vibes</strong>
++ <span>Set funding goals with custom art, celebrate live in chat, and keep a real creator economy — with fair, transparent payouts.</span>
++ </div>
++ <div class="home-cta-feature">
++ <i class="fa-solid fa-wand-magic-sparkles"></i>
++ <strong>AI clips, VODs &amp; transcripts</strong>
++ <span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span>
++ </div>
++ <div class="home-cta-feature">
++ <i class="fa-solid fa-paste"></i>
++ <strong>Pastes &amp; file sharing</strong>
++ <span>Share code, logs and files with syntax highlighting and a short link. Pastes live on OpenVibe.Community and still show up on your channel.</span>
++ </div>
++ <div class="home-cta-feature">
++ <i class="fa-solid fa-screwdriver-wrench"></i>
++ <strong>160+ online tools</strong>
++ <span>Image &amp; audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all at openvibe.tools.</span>
++ </div>
++ <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. 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="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>
++ </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 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>
++ <span>Hosting, bandwidth, storage and development come first. Streaming is not free to host, and being honest about that is how the network stays open to everyone.</span>
++ </div>
++ <div class="home-cta-feature">
++ <i class="fa-solid fa-heart"></i>
++ <strong>Back the creators</strong>
++ <span>What is left over goes to the people who fill the place: creator payouts, community events, prize pools and the tools streamers ask for.</span>
++ </div>
++ <div class="home-cta-feature">
++ <i class="fa-solid fa-code-branch"></i>
++ <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 -->
++ </div>
++ </div>
++
++</div>
++
++<div class="container">
++ <div class="network-hub">
++ <div class="network-hub-header">
++ <i class="fa-solid fa-network-wired"></i>
++ <div>
++ <h3>The OpenVibe Network</h3>
++ <p>One account, 14 services — streaming, games, maps, food, image tools, audio tools, text generators, logo makers, PDF &amp; document tools, network diagnostics, developer tools, video downloads, and more.</p>
++ </div>
++ </div>
++ <div class="network-hub-grid">
++ <a href="https://openvibe.live" class="network-card network-card-featured">
++ <div class="network-card-icon" style="--card-accent:#e74c3c"><i class="fa-solid fa-tower-broadcast"></i></div>
++ <div class="network-card-info">
++ <strong>OpenVibe.<wbr>Live</strong>
++ <span>Live streaming, VODs, clips &amp; chat</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#3b82f6"><i class="fa-solid fa-screwdriver-wrench"></i></div>
++ <div class="network-card-info">
++ <strong>OpenVibe.<wbr>Tools</strong>
++ <span>Every online tool in one index</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.community" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#22c55e"><i class="fa-solid fa-people-group"></i></div>
++ <div class="network-card-info">
++ <strong>OpenVibe.<wbr>Community</strong>
++ <span>Pastes, posts and people</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.games" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#a855f7"><i class="fa-solid fa-hat-wizard"></i></div>
++ <div class="network-card-info">
++ <strong>Scraplandia</strong>
++ <span>Browser RPG &amp; community games</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.media" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#f59e0b"><i class="fa-solid fa-photo-film"></i></div>
++ <div class="network-card-info">
++ <strong>OpenVibe.<wbr>Media</strong>
++ <span>VODs, clips and files</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://yt.openvibe.tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#ef4444"><i class="fa-solid fa-circle-play"></i></div>
++ <div class="network-card-info">
++ <strong>YouTube Downloader</strong>
++ <span>Save video or audio</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.tools/image-tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#ec4899"><i class="fa-solid fa-image"></i></div>
++ <div class="network-card-info">
++ <strong>Image Tools</strong>
++ <span>Convert, compress, resize, crop</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.tools/audio-tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#14b8a6"><i class="fa-solid fa-music"></i></div>
++ <div class="network-card-info">
++ <strong>Audio Tools</strong>
++ <span>Convert, trim, pitch and clean up</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.tools/pdf-tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#f97316"><i class="fa-solid fa-file-pdf"></i></div>
++ <div class="network-card-info">
++ <strong>PDF &amp; Documents</strong>
++ <span>Merge, split, compress, protect</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.tools/text-tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#8b5cf6"><i class="fa-solid fa-font"></i></div>
++ <div class="network-card-info">
++ <strong>Text &amp; Logo Tools</strong>
++ <span>Fancy text, logos, counters</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.tools/developer-tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#06b6d4"><i class="fa-solid fa-code"></i></div>
++ <div class="network-card-info">
++ <strong>Developer Tools</strong>
++ <span>JSON, regex, JWT, diff, hashes</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.tools/network-tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#10b981"><i class="fa-solid fa-network-wired"></i></div>
++ <div class="network-card-info">
++ <strong>Network Tools</strong>
++ <span>DNS, WHOIS, SSL, ping, ports</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://maps.openvibe.tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#2ecc71"><i class="fa-solid fa-map-location-dot"></i></div>
++ <div class="network-card-info">
++ <strong>Survival Map</strong>
++ <span>Camps, resources &amp; shelters</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://food.openvibe.tools" class="network-card">
++ <div class="network-card-icon" style="--card-accent:#eab308"><i class="fa-solid fa-utensils"></i></div>
++ <div class="network-card-info">
++ <strong>Budget Food Finder</strong>
++ <span>Cheap meals and food banks</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ <a href="https://openvibe.network/my" class="network-card">
++ <div class="network-card-icon" style="--card-accent:var(--accent, #3b82f6)"><i class="fa-solid fa-user-circle"></i></div>
++ <div class="network-card-info">
++ <strong>My Account</strong>
++ <span>Profile, themes, history, linked services</span>
++ </div>
++ <i class="fa-solid fa-arrow-right network-card-arrow"></i>
++ </a>
++ </div>
++ </div>
++
++ <!-- OpenVibe.Live Changelog -->
++ <div class="section-header" style="--sc:#94a3b8;margin-top:32px;">
++ <h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>
++ <a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="section-action">View all</a>
++ </div>
++ <div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed">
++ <div id="home-changelog" class="home-changelog">
++ <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
++ </div>
++ <div class="home-changelog-fade" id="home-changelog-fade"></div>
++ </div>
++ <!-- Expands the preview in place; "View all" in the header opens /updates. It sits under
++ the list it expands, in the same button style as the rails' "Load more". -->
++ <button type="button" id="home-changelog-toggle" class="home-load-more home-changelog-toggle" onclick="toggleHomeChangelog()" aria-expanded="false" aria-controls="home-changelog-wrapper"><i class="fa-solid fa-chevron-down" aria-hidden="true"></i><span>Show all recent changes</span></button>
++
++</div>
ddiff --git a/public/fragments/rundown.html b/public/fragments/rundown.html
nnew file mode 100644
iindex 00000000..30955a3c
---- /dev/null
++++ b/public/fragments/rundown.html
@@@ -0,0 +1,5 @@
++<div class="content-container rundown-container">
++ <div id="rundown-root">
++ <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
++ </div>
++</div>
ddiff --git a/public/fragments/updates.html b/public/fragments/updates.html
nnew file mode 100644
iindex 00000000..dae35403
---- /dev/null
++++ b/public/fragments/updates.html
@@@ -0,0 +1,9 @@
++<div class="content-container">
++ <div class="page-header">
++ <h1><i class="fa-solid fa-code-commit"></i> Updates</h1>
++ <p class="page-subtitle">Every change deployed to OpenVibe.Live, newest first: each line is a commit from the <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener">OpenVibers repositories</a>, and batches become <a href="https://openvibe.blog/@openvibe" rel="noopener">Patch notes on openvibe.blog</a>. See <a href="https://openvibe.network/updates">the whole network</a>.</p>
++ </div>
++ <div id="updates-list" class="updates-list">
++ <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
++ </div>
++</div>
ddiff --git a/public/index.html b/public/index.html
iindex a466e4da..8ff76e6c 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -167,511 +167,7 @@
<main id="app">
<!-- ── HOME: Stream List ─────────────────────────────── -->
-- <section id="page-home" class="page active">
-- <div class="hero" data-fx-viewport>
-- <div class="hero-bg" id="hero-bg" aria-hidden="true"><div class="hero-bg-layer"></div><div class="hero-bg-layer"></div></div>
-- <div class="hero-aurora" aria-hidden="true"></div>
-- <div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
-- <div class="hero-scrim" aria-hidden="true"></div>
-- <div class="hero-content">
-- <h1><span class="brand-mark brand-mark--hero" aria-hidden="true"><svg viewBox="0 0 48 48">
-- <defs>
-- <linearGradient id="ovh-g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
-- <linearGradient id="ovh-v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
-- <path id="ovh-ring" d="M24,6 A18,18 0 1,1 23.99,6 Z"/>
-- </defs>
-- <circle class="ov-glow" cx="24" cy="24" r="21"/>
-- <circle class="ov-ring" cx="24" cy="24" r="18"/>
-- <circle class="ov-comet" cx="24" cy="24" r="18" stroke="url(#ovh-g)"/>
-- <path class="ov-v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#ovh-v)"/>
-- <circle class="ov-dot" cx="24" cy="34" r="2.6"/>
-- <circle class="ov-orbit" r="2"><animateMotion dur="3.6s" repeatCount="indefinite" rotate="auto"><mpath href="#ovh-ring"/></animateMotion></circle>
-- <circle class="ov-orbit ov-orbit--tail" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#ovh-ring"/></animateMotion></circle>
-- <circle class="ov-spark" cx="24" cy="24" r="1.3"/><circle class="ov-spark ov-spark--2" cx="24" cy="24" r="1"/><circle class="ov-spark ov-spark--3" cx="24" cy="24" r="1.1"/>
-- </svg></span> OpenVibe.Live</h1>
-- <!-- The first word and quip ship in the HTML so the hero is complete at first paint; the
-- rotators take over once the page's data arrives. No aria-live: a slogan that changes
-- every few seconds would be read out to screen-reader users over and over. -->
-- <p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate visible" id="hero-rotate">stealth campers</span></span></p>
-- <p class="hero-quip visible" id="hero-quip">No investors. No suits. Just vibes.</p>
-- <!-- Holds its line from the first paint (the countdown fills it with the page's data), so the buttons below do not move. -->
-- <div class="hero-slogan-timer" id="hero-slogan-timer">&nbsp;</div>
-- <!-- One primary action and its promise, then the other destinations (owner, 2026-10-18: "whole area is kinda
-- messy and spacing is weird"; six equal buttons wrapped 5 + 1 with the no-OBS line floating between
-- rows). The headline feature stays in the first screen: going live from a browser with no OBS and
-- no follower minimum is what people search and ask AI assistants for. -->
-- <div class="hero-cta" data-fx-viewport>
-- <div class="hero-primary">
-- <button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Go live <small>in your browser</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
-- <p class="hero-golive-note"><span><i class="fa-solid fa-check" aria-hidden="true"></i> No OBS</span><span><i class="fa-solid fa-check" aria-hidden="true"></i> No downloads</span><span><i class="fa-solid fa-check" aria-hidden="true"></i> No follower minimum</span><a href="/docs/go-live-in-your-browser">How it works <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></p>
-- </div>
-- <nav class="hero-links" aria-label="More from OpenVibe">
-- <a href="https://openvibe.tools" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
-- <a href="https://openvibe.games" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
-- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:3" class="hb hb-vibe"
-- data-ovtip-title="Mod it, bot it, build on it"
-- data-ovtip-icon="fa-wand-magic-sparkles"
-- data-ovtip-accent="#f472b6"
-- data-ovtip-place="top"
-- data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games &mdash; every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
-- data-ovtip-foot="<i class=&quot;fa-solid fa-heart&quot;></i> We love what people build here &mdash; come make something"
-- data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">&amp; Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
-- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:4" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
-- <a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:5" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
-- </nav>
-- </div>
-- <!-- Signed-in streamers with setup left to do see their next task here, in the row of
-- things to do rather than a screen below (public/js/guides-live.js → setupHeroNextUp). -->
-- <div class="tour-quest-slot hero-quest-slot" id="hero-next-up" data-fx-viewport hidden></div>
-- <div class="hero-philosophy-links">
-- <a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
-- <i class="fa-solid fa-book-open"></i> What is OpenVibe?
-- </a>
-- <span class="hero-philosophy-sep" aria-hidden="true">&bull;</span>
-- <span class="hero-legal"><a href="/tos" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/tos')">Terms</a>
-- <span aria-hidden="true">&bull;</span>
-- <a href="/privacy" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/privacy')">Privacy</a>
-- <span aria-hidden="true">&bull;</span>
-- <a href="/dmca" class="hero-philosophy-link" onclick="return handleLinkClick(event, '/dmca')">DMCA</a></span>
-- </div>
-- <!-- Newest shipped commit — built in the open, receipts included. Sits under the
-- philosophy row so the proof is next to the promise. -->
-- <a class="hero-latest" id="hero-latest" data-ov-shipped="latest" data-service="live" href="/updates" onclick="return handleLinkClick(event, '/updates')"></a>
-- <div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
-- </div>
-- </div>
--
-- <div class="container">
-- <!-- Featured live stream (public/js/home-featured.js): filled only when someone is live. -->
-- <div class="home-featured" id="home-featured" hidden></div>
-- <!-- Live Streams -->
-- <div class="section-header" style="--sc:#ef4444">
-- <h2><i class="fa-solid fa-circle live-dot"></i> Live Now<small class="section-kicker">on air</small></h2>
-- <span id="live-count" class="badge">0</span>
-- <button type="button" class="home-featured-toggle" id="home-featured-toggle" hidden><i class="fa-solid fa-satellite-dish"></i> Show featured stream</button>
-- </div>
-- <div class="stream-grid" id="stream-grid-live">
-- <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
-- <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
-- <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
-- <div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
-- <div class="empty-state" id="no-live-streams" style="display:none">
-- <i class="fa-solid fa-tower-broadcast fa-3x"></i>
-- <p>No one is streaming right now</p>
-- <!-- This was a line of grey text telling people to go live with no way to do it.
-- It is the same button as the one under the tour, routed through the same
-- entry point, so signing in lands you back in the go-live journey. -->
-- <button type="button" class="ovg-firstlive" onclick="if (window.startGoLiveJourney) startGoLiveJourney(); else location.href='/broadcast';">
-- <span class="ovg-firstlive-ring" aria-hidden="true"></span>
-- <span class="ovg-firstlive-sheen" aria-hidden="true"></span>
-- <i class="fa-solid fa-tower-broadcast"></i>
-- <span>Be the first to go live</span>
-- <i class="fa-solid fa-arrow-right ovg-firstlive-go"></i>
-- </button>
-- </div>
-- </div>
--
-- <!-- "While you were away" (signed-in, returning) / "Lately on OpenVibe" (everyone) — site activity digest -->
-- <div class="home-digest" id="home-digest"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
--
-- <!-- Star of OpenVibe — featured streamer spotlight (star_streamer setting / STAR_STREAMER env) -->
-- <div class="home-star-section" id="home-star-section"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div><div class="ovsk-bar" style="height:10px;width:80%"></div></div></div>
--
-- <div class="hero-egg" id="hero-egg" style="display:none">
-- <button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
-- <span class="hero-egg-icon">🥚</span>
-- <span class="hero-egg-titles"><span class="hero-egg-kicker">Today's Secret</span><span class="hero-egg-title" id="hero-egg-title"></span></span>
-- <span class="hero-egg-status" id="hero-egg-status"></span>
-- <i class="fa-solid fa-chevron-down hero-egg-chev"></i>
-- </button>
-- <div class="hero-egg-body">
-- <div class="hero-egg-inner">
-- <p class="hero-egg-intro">A new secret every day. Each clue is one key, in order. Type the keys anywhere on the site, or tap them below.</p>
-- <div class="hero-egg-slots" id="hero-egg-slots" role="group" aria-label="Your key sequence"></div>
-- <ol class="hero-egg-clues" id="hero-egg-clues"></ol>
-- <div class="hero-egg-pad" id="hero-egg-pad">
-- <div class="hero-egg-pad-arrows">
-- <button type="button" class="egg-key" data-tok="left" aria-label="Left">←</button>
-- <button type="button" class="egg-key" data-tok="up" aria-label="Up">↑</button>
-- <button type="button" class="egg-key" data-tok="down" aria-label="Down">↓</button>
-- <button type="button" class="egg-key" data-tok="right" aria-label="Right">→</button>
-- <button type="button" class="egg-key egg-key--ctl" data-act="back" aria-label="Backspace">⌫</button>
-- <button type="button" class="egg-key egg-key--ctl" data-act="clear" aria-label="Clear">✕</button>
-- </div>
-- <div class="hero-egg-pad-letters" id="hero-egg-letters"></div>
-- </div>
-- <div class="hero-egg-foot">
-- <button type="button" class="hero-egg-reveal" id="hero-egg-reveal" onclick="_eggReveal()"><i class="fa-solid fa-lightbulb"></i> Stuck? Reveal a key</button>
-- <div class="hero-egg-meta"><span id="hero-egg-count"></span><span id="hero-egg-first"></span><span id="hero-egg-reset"></span></div>
-- </div>
-- </div>
-- </div>
-- </div>
--
-- <!-- Community pulse: goals near completion, latest activity, weekly leaders -->
-- <div class="home-section-lazy" id="home-pulse-section" style="display:none">
-- <div class="section-header" style="--sc:#f59e0b">
-- <h2><i class="fa-solid fa-bolt"></i> Happening Now<small class="section-kicker">the pulse</small></h2>
-- </div>
-- <div class="pulse-grid" id="pulse-grid"></div>
-- </div>
--
-- <!-- What people made, above the introduction to the site (roadmap 32.7: the home leads with live
-- content and creators): channels that were just on, their recordings, the clips people took
-- and their pastes. What the AI made follows it (AI Moments; 33.6: made, then derived). -->
-- <!-- Recently Online -->
-- <div class="section-header" style="--sc:#60a5fa">
-- <h2><i class="fa-solid fa-clock-rotate-left"></i> Recently Online<small class="section-kicker">just missed them</small></h2>
-- </div>
-- <div class="stream-grid" id="stream-grid-recent">
-- <div class="empty-state">
-- <p class="muted">No recent streams</p>
-- </div>
-- </div>
-- <div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
--
-- <!-- Recent VODs -->
-- <div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
-- <h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
-- <a href="/content?type=vods" onclick="return handleLinkClick(event, '/content?type=vods')" class="section-action">View all</a>
-- </div>
-- <div class="stream-grid" id="home-recent-vods-grid"></div>
-- <div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
--
-- <!-- Recent Clips -->
-- <div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
-- <h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
-- <a href="/content?type=clips" onclick="return handleLinkClick(event, '/content?type=clips')" class="section-action">View all</a>
-- </div>
-- <div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
-- <div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
--
-- <!-- Recent Pastes -->
-- <div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
-- <h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
-- <a href="/content?type=pastes" onclick="return handleLinkClick(event, '/content?type=pastes')" class="section-action">View all</a>
-- </div>
-- <div class="home-pastes-list" id="home-pastes-list"></div>
-- <div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
--
-- <!-- AI Moments showcase -->
-- <div class="home-section-lazy" id="home-moments-section" style="display:none">
-- <div class="section-header" style="--sc:#22d3ee">
-- <h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
-- <a href="/moments" onclick="return handleLinkClick(event, '/moments')" class="section-action">See all</a>
-- </div>
-- <div class="moments-row ov-rail-track" id="home-moments-row"></div>
-- </div>
--
-- <!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
-- why it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->
-- <!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
-- <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-fx-viewport></div>
-- <!-- Streaming CTA -->
-- <div class="home-cta-banner" id="home-cta-banner" data-fx-viewport>
-- <div class="cta-ambient" aria-hidden="true"></div>
-- <div class="cta-embers" aria-hidden="true">
-- <span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
-- </div>
-- <div class="home-cta-inner">
--
-- <!-- The all-caps eyebrow here ("HOME-GROWN & HOBBYIST-RUN — NOT ANOTHER CORPORATE
-- STREAM SITE") was a louder restatement of the lede two lines below it, which
-- already says the platform is built by one person who streams here. One of them
-- is enough, and the lede is the one that reads like a human wrote it. -->
-- <div class="cta-chips" aria-hidden="true">
-- <span class="cta-chip"><i class="fa-solid fa-people-group"></i> Community-run</span>
-- <span class="cta-chip"><i class="fa-solid fa-comment"></i> Free speech, light rules</span>
-- <span class="cta-chip"><i class="fa-solid fa-code-branch"></i> Built in the open</span>
-- </div>
-- <h2 class="cta-hero">Streaming the way it <span class="cta-hero-grad">should</span> be.</h2>
-- <p class="cta-lede">
-- An <strong>open, community-run</strong> streaming platform, built in public by people who stream and hang out in chat. The ideas from chat are the ones that ship. <strong>Free speech, light-touch moderation, no mystery bans.</strong>
-- </p>
--
-- <!-- Always shown (not behind "See what makes it different"): going live from the browser with
-- no OBS and no follower minimum is what people search and ask AI assistants for. -->
-- <div class="cta-actions">
-- <a class="cta-btn cta-btn-primary" href="/broadcast" onclick="return goLiveNav(event)"><i class="fa-solid fa-bolt" aria-hidden="true"></i> Go live from your browser</a>
-- <a class="cta-btn cta-btn-ghost" href="/docs/go-live-in-your-browser">How it works</a>
-- </div>
-- <ul class="cta-checks" aria-label="What going live needs">
-- <li><i class="fa-solid fa-check" aria-hidden="true"></i> No OBS</li>
-- <li><i class="fa-solid fa-check" aria-hidden="true"></i> No downloads</li>
-- <li><i class="fa-solid fa-check" aria-hidden="true"></i> No follower minimum</li>
-- <li><i class="fa-solid fa-check" aria-hidden="true"></i> Chromebook, laptop or phone</li>
-- </ul>
--
-- <button class="home-about-toggle" type="button" onclick="toggleHomeAbout()" aria-expanded="false" aria-controls="home-about-body">
-- <span class="home-about-toggle-more"><i class="fa-solid fa-circle-plus"></i> See what makes it different</span>
-- <span class="home-about-toggle-less"><i class="fa-solid fa-circle-minus"></i> Show less</span>
-- </button>
--
-- <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</div>
-- <div class="cta-methods">
-- <div class="cta-method">
-- <div class="cta-method-num">01</div>
-- <div class="cta-method-icon"><i class="fa-solid fa-bolt"></i></div>
-- <strong>Right in your browser</strong>
-- <span>One click, zero software. Powered by <b>WebRTC</b> for <em>sub-second, ultra-low latency</em> — the fastest way to go live anywhere.</span>
-- <div class="cta-method-tag">WebRTC · &lt;1s latency</div>
-- </div>
-- <div class="cta-method">
-- <div class="cta-method-num">02</div>
-- <div class="cta-method-icon"><i class="fa-solid fa-sliders"></i></div>
-- <strong>OBS &amp; RTMP</strong>
-- <span>Plug in OBS, Streamlabs, or any RTMP encoder for a full scene-based, pro-grade production workflow.</span>
-- <div class="cta-method-tag">RTMP · pro setups</div>
-- </div>
-- <div class="cta-method">
-- <div class="cta-method-num">03</div>
-- <div class="cta-method-icon"><i class="fa-solid fa-terminal"></i></div>
-- <strong>ffmpeg &amp; CLI</strong>
-- <span>Headless and fully scriptable — stream straight from a Raspberry Pi, a server, or an embedded device.</span>
-- <div class="cta-method-tag">jsmpeg · headless</div>
-- </div>
-- </div>
--
-- <!-- Differentiators -->
-- <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 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>
-- <strong>Restream everywhere at once</strong>
-- <span>Push a single broadcast to Twitch, YouTube, Kick, RobotStreamer &amp; any RTMP server simultaneously — from one dashboard.</span>
-- </div>
-- <div class="home-cta-feature">
-- <i class="fa-solid fa-gamepad"></i>
-- <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>
-- <strong>Global chat + voice</strong>
-- <span>A sitewide chatroom and drop-in voice channels connect everyone across every stream — no Discord required.</span>
-- </div>
-- <div class="home-cta-feature">
-- <i class="fa-solid fa-bullseye"></i>
-- <strong>Donation goals &amp; Vibes</strong>
-- <span>Set funding goals with custom art, celebrate live in chat, and keep a real creator economy — with fair, transparent payouts.</span>
-- </div>
-- <div class="home-cta-feature">
-- <i class="fa-solid fa-wand-magic-sparkles"></i>
-- <strong>AI clips, VODs &amp; transcripts</strong>
-- <span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span>
-- </div>
-- <div class="home-cta-feature">
-- <i class="fa-solid fa-paste"></i>
-- <strong>Pastes &amp; file sharing</strong>
-- <span>Share code, logs and files with syntax highlighting and a short link. Pastes live on OpenVibe.Community and still show up on your channel.</span>
-- </div>
-- <div class="home-cta-feature">
-- <i class="fa-solid fa-screwdriver-wrench"></i>
-- <strong>160+ online tools</strong>
-- <span>Image &amp; audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all at openvibe.tools.</span>
-- </div>
-- <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. 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="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>
-- </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 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>
-- <span>Hosting, bandwidth, storage and development come first. Streaming is not free to host, and being honest about that is how the network stays open to everyone.</span>
-- </div>
-- <div class="home-cta-feature">
-- <i class="fa-solid fa-heart"></i>
-- <strong>Back the creators</strong>
-- <span>What is left over goes to the people who fill the place: creator payouts, community events, prize pools and the tools streamers ask for.</span>
-- </div>
-- <div class="home-cta-feature">
-- <i class="fa-solid fa-code-branch"></i>
-- <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 -->
-- </div>
-- </div>
--
-- </div>
--
-- <div class="container">
-- <div class="network-hub">
-- <div class="network-hub-header">
-- <i class="fa-solid fa-network-wired"></i>
-- <div>
-- <h3>The OpenVibe Network</h3>
-- <p>One account, 14 services — streaming, games, maps, food, image tools, audio tools, text generators, logo makers, PDF &amp; document tools, network diagnostics, developer tools, video downloads, and more.</p>
-- </div>
-- </div>
-- <div class="network-hub-grid">
-- <a href="https://openvibe.live" class="network-card network-card-featured">
-- <div class="network-card-icon" style="--card-accent:#e74c3c"><i class="fa-solid fa-tower-broadcast"></i></div>
-- <div class="network-card-info">
-- <strong>OpenVibe.<wbr>Live</strong>
-- <span>Live streaming, VODs, clips &amp; chat</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#3b82f6"><i class="fa-solid fa-screwdriver-wrench"></i></div>
-- <div class="network-card-info">
-- <strong>OpenVibe.<wbr>Tools</strong>
-- <span>Every online tool in one index</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.community" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#22c55e"><i class="fa-solid fa-people-group"></i></div>
-- <div class="network-card-info">
-- <strong>OpenVibe.<wbr>Community</strong>
-- <span>Pastes, posts and people</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.games" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#a855f7"><i class="fa-solid fa-hat-wizard"></i></div>
-- <div class="network-card-info">
-- <strong>Scraplandia</strong>
-- <span>Browser RPG &amp; community games</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.media" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#f59e0b"><i class="fa-solid fa-photo-film"></i></div>
-- <div class="network-card-info">
-- <strong>OpenVibe.<wbr>Media</strong>
-- <span>VODs, clips and files</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://yt.openvibe.tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#ef4444"><i class="fa-solid fa-circle-play"></i></div>
-- <div class="network-card-info">
-- <strong>YouTube Downloader</strong>
-- <span>Save video or audio</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.tools/image-tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#ec4899"><i class="fa-solid fa-image"></i></div>
-- <div class="network-card-info">
-- <strong>Image Tools</strong>
-- <span>Convert, compress, resize, crop</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.tools/audio-tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#14b8a6"><i class="fa-solid fa-music"></i></div>
-- <div class="network-card-info">
-- <strong>Audio Tools</strong>
-- <span>Convert, trim, pitch and clean up</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.tools/pdf-tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#f97316"><i class="fa-solid fa-file-pdf"></i></div>
-- <div class="network-card-info">
-- <strong>PDF &amp; Documents</strong>
-- <span>Merge, split, compress, protect</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.tools/text-tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#8b5cf6"><i class="fa-solid fa-font"></i></div>
-- <div class="network-card-info">
-- <strong>Text &amp; Logo Tools</strong>
-- <span>Fancy text, logos, counters</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.tools/developer-tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#06b6d4"><i class="fa-solid fa-code"></i></div>
-- <div class="network-card-info">
-- <strong>Developer Tools</strong>
-- <span>JSON, regex, JWT, diff, hashes</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.tools/network-tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#10b981"><i class="fa-solid fa-network-wired"></i></div>
-- <div class="network-card-info">
-- <strong>Network Tools</strong>
-- <span>DNS, WHOIS, SSL, ping, ports</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://maps.openvibe.tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#2ecc71"><i class="fa-solid fa-map-location-dot"></i></div>
-- <div class="network-card-info">
-- <strong>Survival Map</strong>
-- <span>Camps, resources &amp; shelters</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://food.openvibe.tools" class="network-card">
-- <div class="network-card-icon" style="--card-accent:#eab308"><i class="fa-solid fa-utensils"></i></div>
-- <div class="network-card-info">
-- <strong>Budget Food Finder</strong>
-- <span>Cheap meals and food banks</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- <a href="https://openvibe.network/my" class="network-card">
-- <div class="network-card-icon" style="--card-accent:var(--accent, #3b82f6)"><i class="fa-solid fa-user-circle"></i></div>
-- <div class="network-card-info">
-- <strong>My Account</strong>
-- <span>Profile, themes, history, linked services</span>
-- </div>
-- <i class="fa-solid fa-arrow-right network-card-arrow"></i>
-- </a>
-- </div>
-- </div>
--
-- <!-- OpenVibe.Live Changelog -->
-- <div class="section-header" style="--sc:#94a3b8;margin-top:32px;">
-- <h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>
-- <a href="/updates" onclick="return handleLinkClick(event, '/updates')" class="section-action">View all</a>
-- </div>
-- <div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed">
-- <div id="home-changelog" class="home-changelog">
-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
-- </div>
-- <div class="home-changelog-fade" id="home-changelog-fade"></div>
-- </div>
-- <!-- Expands the preview in place; "View all" in the header opens /updates. It sits under
-- the list it expands, in the same button style as the rails' "Load more". -->
-- <button type="button" id="home-changelog-toggle" class="home-load-more home-changelog-toggle" onclick="toggleHomeChangelog()" aria-expanded="false" aria-controls="home-changelog-wrapper"><i class="fa-solid fa-chevron-down" aria-hidden="true"></i><span>Show all recent changes</span></button>
--
--
--
--
-- </div>
-- </section>
++ <section id="page-home" class="page active" data-fragment="home"><!--ov:fragment:home--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ── STREAM VIEWER (legacy, redirect to channel) ──── -->
<section id="page-stream" class="page">
@@@ -724,17 +220,7 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- UPDATES / CHANGELOG -->
<!-- ═══════════════════════════════════════════════════════════ -->
-- <section id="page-updates" class="page">
-- <div class="content-container">
-- <div class="page-header">
-- <h1><i class="fa-solid fa-code-commit"></i> Updates</h1>
-- <p class="page-subtitle">Every change deployed to OpenVibe.Live, newest first: each line is a commit from the <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener">OpenVibers repositories</a>, and batches become <a href="https://openvibe.blog/@openvibe" rel="noopener">Patch notes on openvibe.blog</a>. See <a href="https://openvibe.network/updates">the whole network</a>.</p>
-- </div>
-- <div id="updates-list" class="updates-list">
-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
-- </div>
-- </div>
-- </section>
++ <section id="page-updates" class="page" data-fragment="updates"><!--ov:fragment:updates--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PASTE VIEWER -->
@@@ -757,13 +243,7 @@
</div>
</section>
-- <section id="page-rundown" class="page">
-- <div class="content-container rundown-container">
-- <div id="rundown-root">
-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
-- </div>
-- </div>
-- </section>
++ <section id="page-rundown" class="page" data-fragment="rundown"><!--ov:fragment:rundown--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-documentation" class="page" data-fragment="documentation"><!--ov:fragment:documentation--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- A path no route knows (the server answers it 404): filled by showNotFound() in app.js -->
ddiff --git a/public/js/app.js b/public/js/app.js
iindex a002d62b..1872b652 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1158,7 +1158,7 @@ function routeFromURL() {
whenRouteReady('rundown', () => { if (typeof loadRundownPage === 'function') loadRundownPage(segments); });
} else if (segments[0] === 'updates') {
showPage('updates');
-- loadUpdatesPage();
++ whenRouteReady('updates', () => loadUpdatesPage());
} else if (segments[0] === 'dmca') {
window.location.replace('/dmca');
return;
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 0e75d217..d6a7f5d4 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -295,14 +295,15 @@ function renderRoute(html, urlPath) {
* visit to a channel, a VOD or the chat painted the home page (its hero and lists) until the router ran and swapped
* pages — a flash of the wrong page and the largest layout shifts measured on the site (CLS 1.0 on a channel page,
* 0.5 on a VOD, phone profile). The route's last feature with a page fragment names its section; the router's own
-- * showPage() then finds it already active.
++ * showPage() then finds it already active. The home page is a fragment too (inlined on "/" only), so on any other
++ * route the shell carries its placeholder, not the hero and its <h1>.
*/
function activePage(html, names, reg) {
const frag = [...names].reverse().map((n) => reg.features[n] && reg.features[n].fragment)
.find((f) => f && html.includes(`class="page" data-fragment="${f}"`));
if (!frag) return html;
return html
-- .replace('<section id="page-home" class="page active">', '<section id="page-home" class="page">')
++ .replace('<section id="page-home" class="page active"', '<section id="page-home" class="page"')
.replace(`class="page" data-fragment="${frag}"`, `class="page active" data-fragment="${frag}"`);
}
ddiff --git a/test/first-paint-stability.test.js b/test/first-paint-stability.test.js
iindex 7a2bc5b0..a4cc99c0 100644
---- a/test/first-paint-stability.test.js
++++ b/test/first-paint-stability.test.js
@@@ -49,8 +49,9 @@ ok('VOD and clip pages live on OpenVibe.Video');
// ── Home hero and early widgets ──
const index = read('public/index.html');
--assert.ok(/<div class="hero-slogan-timer" id="hero-slogan-timer">&nbsp;<\/div>/.test(index), 'the countdown line is held from the first paint');
--assert.ok(/<a class="hero-latest" id="hero-latest"[^>]*><\/a>/.test(index) && !/id="hero-latest"[^>]*\shidden>/.test(index), 'the shipped line is not hidden (shipped.js would collapse it, then refill it)');
++const homeHtml = read('public/fragments/home.html');
++assert.ok(/<div class="hero-slogan-timer" id="hero-slogan-timer">&nbsp;<\/div>/.test(homeHtml), 'the countdown line is held from the first paint');
++assert.ok(/<a class="hero-latest" id="hero-latest"[^>]*><\/a>/.test(homeHtml) && !/id="hero-latest"[^>]*\shidden>/.test(homeHtml), 'the shipped line is not hidden (shipped.js would collapse it, then refill it)');
const home = read('public/css/features/home.css');
assert.ok(home.includes('.hero-latest:empty { visibility: hidden; min-height: 33px; }'), 'the empty shipped line keeps its height, invisibly');
assert.ok(/\.hero-stats \{ margin: 16px 0 10px; gap: 10px; min-height: 120px; \}/.test(home) && home.includes('.hero-stats { min-height: 204px; }') && home.includes('.hero-stats { min-height: 256px; margin-top: 14px; }'),
ddiff --git a/test/shell-one-h1.test.js b/test/shell-one-h1.test.js
nnew file mode 100644
iindex 00000000..655b26bd
---- /dev/null
++++ b/test/shell-one-h1.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Every page's shell carries one <h1>: its own. The home page (hero and lists), the Updates page and the Rundown
++ * are fragments like the other pages, inlined only on their own route (server/web/assets.js renderRoute). Before
++ * 2026-10-24 every page shipped the home hero's <h1> and the Updates page's, so a channel page had three, and
++ * every page that was not the home page carried the home page's 37 KB of markup.
++ *
++ * node test/shell-one-h1.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const assets = require('../server/web/assets');
++try { assets.setSharedDir(require('openvibe-shared/files').dir); } catch { /* */ }
++
++const doc = assets.document('index.html').html;
++const h1s = (html) => (html.replace(/<template[\s\S]*?<\/template>/gi, '').match(/<h1\b/gi) || []).length;
++assert.strictEqual(h1s(doc), 0, 'the bare shell has no page markup with an <h1>');
++
++for (const url of ['/', '/@someone', '/updates', '/content', '/clips', '/moments', '/search', '/rundown', '/documentation', '/chat', '/dashboard', '/broadcast']) {
++ const html = assets.renderRoute(doc, url);
++ assert.ok(h1s(html) <= 1, `${url}: at most one <h1> (${h1s(html)})`);
++ assert.strictEqual(html.includes('class="hero-tagline"'), url === '/', `${url}: the home hero ${url === '/' ? 'is' : 'is not'} in the page`);
++}
++for (const url of ['/', '/updates', '/content', '/moments', '/search', '/@someone']) {
++ assert.strictEqual(h1s(assets.renderRoute(doc, url)), 1, `${url}: its page's <h1> is there from the first paint`);
++}
++
++// The home, Updates and Rundown pages are features with fragments, and the router waits for them.
++const reg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'features.json'), 'utf8'));
++for (const [feature, frag] of [['home', 'home'], ['updates', 'updates'], ['rundown', 'rundown']]) {
++ assert.strictEqual(reg.features[feature].fragment, frag, `${feature} has its fragment`);
++ assert.ok(fs.existsSync(path.join(__dirname, '..', 'public', 'fragments', `${frag}.html`)), `${frag}.html exists`);
++ assert.ok(doc.includes(`data-fragment="${frag}"><!--ov:fragment:${frag}-->`), `the shell holds ${frag}'s placeholder`);
++}
++assert.ok(reg.routes.some((r) => new RegExp(r.path).test('/updates') && r.features.includes('updates')), '/updates loads its feature');
++const app = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
++assert.ok(app.includes("whenRouteReady('updates', () => loadUpdatesPage())"), 'the Updates page fills once its markup is in');
++
++// A route of another page keeps the home placeholder inactive; "/" keeps it active.
++const active = (html) => [...html.matchAll(/<section id="(page-[a-z-]+)" class="page active"/g)].map((m) => m[1]);
++assert.deepStrictEqual(active(assets.renderRoute(doc, '/')), ['page-home']);
++assert.deepStrictEqual(active(assets.renderRoute(doc, '/rundown')), ['page-rundown']);
++assert.deepStrictEqual(active(assets.renderRoute(doc, '/updates')), ['page-updates']);
++
++console.log('shell one h1: all passed');
ddiff --git a/test/shipped-live.test.js b/test/shipped-live.test.js
iindex 2e6dbb29..fd1d183c 100644
---- a/test/shipped-live.test.js
++++ b/test/shipped-live.test.js
@@@ -11,8 +11,8 @@ const ROOT = path.join(__dirname, '..');
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
// The shared views
--const index = read('public/index.html');
--assert.ok(/id="hero-latest" data-ov-shipped="latest" data-service="live" href="\/updates"/.test(index), 'the hero one-liner is the shared view');
++const homeHtml = read('public/fragments/home.html');
++assert.ok(/id="hero-latest" data-ov-shipped="latest" data-service="live" href="\/updates"/.test(homeHtml), 'the hero one-liner is the shared view');
const app = read('public/js/app.js');
assert.ok(app.includes("sc.src = '/shared/shipped.js'") && app.includes("sc.setAttribute('data-ov-shipped-src', '')"), 'shipped.js from this site\'s own /shared/ pin, with the footer\'s marker');
assert.ok(/shipped\.log\(container, \{ service: 'live'/.test(app), '/updates is the shared log');