openvibers/openvibe.live Commits

Home on phones, part 2: two more tabbed blocks (#462)

OP OpenVibers committed 14 days ago · eabbb9d2d2 · 6 files, +446 −74

Around OpenVibe (the digest, the star spotlight, the pulse) and New here?
(the restream tour, the About banner, the network hub) share one place on a
phone: a row of tabs and one section at a time. The tab names the section, so
the section's own heading goes and its header keeps what it does. Wider
screens never see the tabs and show every section, as before.

initHomeCatchup() becomes the generic initHomeTabs(boxId, shownIds,
storageKey, onOpen); Catch up uses it unchanged and its test still passes. A
tab shows once its section has something, arrow keys move between the tabs
that show, the kept tab opens in the first three seconds only, and a section
that empties out falls back to one that has something.

demoteHomeIntroSections() moves the whole New here? block as one, and the
block still works where it lands. The tour loads when its tab opens (the
lazy loader can be forced), and the About banner counts as seen when its tab
is opened, so a reader on a phone still counts as having seen both.

The phone home was 8.1 screens at 390 px with its lazy sections loaded; it is
6.1 now. The daily egg stays where it was, and the changelog preview is
untouched.

Changes

ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex 719c9cd1..83318b9a 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -85,6 +85,8 @@
".chat-badge-streamer",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::before",
++ ".chat-conn-status",
++ ".chat-conn-status[data-state=\"connected\"]",
".chat-crossfeed-badge",
".chat-crossfeed-global",
".chat-crossfeed-stream",
@@@ -189,7 +191,6 @@
".hero-float",
".hero-float img",
".hero-float--clip .hero-float-badge",
-- ".hero-float--moment .hero-float-badge",
".hero-float--paste .hero-float-badge",
".hero-float--vod .hero-float-badge",
".hero-float-body",
@@@ -292,6 +293,12 @@
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
".openvibe-navbar.navbar.ovnav-top",
".ov-comet",
++ ".ov-conn-pill",
++ ".ov-conn-pill .ov-conn-dot",
++ ".ov-conn-pill.is-shown",
++ ".ov-conn-pill[data-state=\"connected\"] .ov-conn-dot",
++ ".ov-conn-pill[data-state=\"reconnecting\"] .ov-conn-dot",
++ ".ov-conn-pill[data-state=\"updating\"] .ov-conn-dot",
".ov-dot",
".ov-glow",
".ov-orbit",
@@@ -299,7 +306,6 @@
".ov-rail",
".ov-rail > .moments-row",
".ov-rail-bar",
-- ".ov-rail-bar > span",
".ov-rail-bar[hidden]",
".ov-rail-btn",
".ov-rail-btn:disabled",
@@@ -307,7 +313,6 @@
".ov-rail-prev",
".ov-rail-track",
".ov-rail-track > *",
-- ".ov-rail.is-scrollable > .ov-rail-track",
".ov-rail:not(.is-end) > .ov-rail-track",
".ov-rail:not(.is-scrollable) > .ov-rail-btn",
".ov-rail:not(.is-start) > .ov-rail-track",
@@@ -348,7 +353,6 @@
".section-header h2 > i:first-child",
".section-header h2 > i:first-child::before",
".section-header::after",
-- ".sgg-title i",
".stream-card",
".stream-card-avatar",
".stream-card-avatar img",
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex dd02e2ef..035ec110 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -698,8 +698,9 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-cta-features { grid-template-columns: 1fr; gap: 9px; }
.home-cta-feature { padding: 13px 14px; }
}
--.home-intro-demoted { margin-top: 26px; }
--#home-tour-mount.home-intro-demoted { border-top: 1px solid var(--border); padding-top: 30px; margin-top: 34px; }
++/* Demoted (a reader who has seen the introductions enough times): the whole "New here?" block goes to the
++ end of the page, so it needs a rule of its own to start a section there. */
++.home-intro-demoted { margin-top: 26px; border-top: 1px solid var(--border); padding-top: 26px; }
.home-pastes-list { grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; }
@media (max-width: 640px) {
.home-pastes-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
@@@ -1267,17 +1268,20 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
#page-home .hero-philosophy-btn::after, #page-home .hero-philosophy-link::after, #page-home .star-name a::after, #page-home .hero-golive-note a::after { content: ''; position: absolute; left: 0; right: 0; top: -12px; bottom: -12px; }
}
--/* Catch up (2026-10-30, the short-pages rule): on a phone the five rails (recently online, VODs, clips, pastes, AI
-- moments) share one place, a row of tabs and one rail at a time; the tab names the rail, so its header keeps only
-- its actions. Wider screens never see the tabs. The breakpoint is app-home.js's _narrow. */
++/* Catch up, Around OpenVibe and New here? (2026-10-30, the short-pages rule): on a phone a block of home
++ sections shares one place — a row of tabs and one of them at a time — so the page stays a couple of screens tall.
++ The tab names the section, so the section's own heading goes and its header keeps only what it does. Wider
++ screens never see the tabs and show every section. The breakpoint is app-home.js's _narrow. */
.home-catchup-tabs { display: none; }
++.home-tabs { display: none; }
@media (max-width: 759px) {
-- .home-catchup-tabs {
++ .home-catchup-tabs, .home-tabs {
display: flex; gap: 6px; margin: 30px -16px 6px; padding: 2px 16px 4px;
overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; scroll-padding-inline: 16px;
}
-- .home-catchup-tabs::-webkit-scrollbar { display: none; }
-- .home-catchup-tabs [role="tab"] {
++ .home-catchup-tabs::-webkit-scrollbar, .home-tabs::-webkit-scrollbar { display: none; }
++ .home-catchup-tabs[hidden], .home-tabs[hidden] { display: none !important; }
++ .home-catchup-tabs [role="tab"], .home-tabs [role="tab"] {
--sc-on: color-mix(in srgb, var(--sc) 45%, var(--accent));
flex: none; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px;
font: inherit; font-size: 0.86rem; font-weight: 750; white-space: nowrap; cursor: pointer;
@@@ -1285,14 +1289,14 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
border: 1px solid var(--border); border-radius: 999px;
transition: color 0.18s, background 0.18s, border-color 0.18s;
}
-- .home-catchup-tabs [role="tab"][hidden] { display: none; }
-- .home-catchup-tabs [role="tab"] i { font-size: 0.78rem; color: var(--sc-on); }
-- .home-catchup-tabs [role="tab"][aria-selected="true"] {
++ .home-catchup-tabs [role="tab"][hidden], .home-tabs [role="tab"][hidden] { display: none; }
++ .home-catchup-tabs [role="tab"] i, .home-tabs [role="tab"] i { font-size: 0.78rem; color: var(--sc-on); }
++ .home-catchup-tabs [role="tab"][aria-selected="true"], .home-tabs [role="tab"][aria-selected="true"] {
color: color-mix(in srgb, var(--sc-on) 30%, var(--text-primary));
background: color-mix(in srgb, var(--sc-on) 14%, transparent);
border-color: color-mix(in srgb, var(--sc-on) 48%, transparent);
}
-- .home-catchup-tabs [role="tab"]:focus-visible { outline: 2px solid var(--sc-on); outline-offset: 2px; }
++ .home-catchup-tabs [role="tab"]:focus-visible, .home-tabs [role="tab"]:focus-visible { outline: 2px solid var(--sc-on); outline-offset: 2px; }
.home-catchup:not([data-tab="recent"]) [data-catchup="recent"],
.home-catchup:not([data-tab="vods"]) [data-catchup="vods"],
.home-catchup:not([data-tab="clips"]) [data-catchup="clips"],
@@@ -1301,5 +1305,24 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-catchup .section-header[data-catchup], .home-catchup [data-catchup] > .section-header { margin: 6px 0 10px; justify-content: flex-end; min-height: 0; }
.home-catchup .section-header[data-catchup] > h2, .home-catchup [data-catchup] > .section-header > h2,
.home-catchup .section-header[data-catchup]::after, .home-catchup [data-catchup] > .section-header::after { display: none; }
--}
--@media (max-width: 759px) and (prefers-reduced-motion: reduce) { .home-catchup-tabs [role="tab"] { transition: none; } }
++ /* The two other blocks: one section at a time behind their own tabs. */
++ .home-tabbox:not([data-tab="lately"]) [data-panel="lately"],
++ .home-tabbox:not([data-tab="star"]) [data-panel="star"],
++ .home-tabbox:not([data-tab="happening"]) [data-panel="happening"],
++ .home-tabbox:not([data-tab="tour"]) [data-panel="tour"],
++ .home-tabbox:not([data-tab="about"]) [data-panel="about"],
++ .home-tabbox:not([data-tab="network"]) [data-panel="network"] { display: none !important; }
++ /* The tab names the section, so the section's own heading goes. What its header does stays. */
++ .home-tabbox [data-panel="star"] > .section-header > h2,
++ .home-tabbox [data-panel="lately"] .digest-title,
++ .home-tabbox [data-panel="network"] .network-hub-header h3 { display: none; }
++ .home-tabbox [data-panel="star"] > .section-header { margin: 6px 0 10px; min-height: 0; }
++ /* The rule the header draws across the page is the slack the note used to take; without the
++ heading there is nothing to rule out to, and the note would run past the section. */
++ .home-tabbox [data-panel="star"] > .section-header::after { display: none; }
++ /* The sentence under a phone header is indented to line up with the title it belongs to; with the
++ title gone it starts at the left and takes the width it needs. */
++ .home-tabbox [data-panel="star"] > .section-header > span.muted { margin-left: 0; flex: 1 1 auto; }
++ .home-tabbox [data-panel="happening"] > .section-header { display: none; }
++}
++@media (max-width: 759px) and (prefers-reduced-motion: reduce) { .home-catchup-tabs [role="tab"], .home-tabs [role="tab"] { transition: none; } }
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 4fbd2be8..2eea7967 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -101,11 +101,21 @@
</div>
</div>
++ <!-- Around OpenVibe (2026-10-30, the short-pages rule): the digest, the star spotlight and the pulse are
++ three different things to look at. On a phone they share one place — a row of tabs and one of them at a
++ time — so the page stays a couple of screens tall. Wider screens hide the tabs and show all three.
++ A tab shows once its section has something: each loader hides a section that has not (initHomeTabs). -->
++ <div class="home-tabbox" id="home-around" data-tab="lately">
++ <div class="home-tabs" role="tablist" aria-label="Around OpenVibe">
++ <button type="button" role="tab" id="ha-tab-lately" data-tab="lately" aria-selected="true" aria-controls="home-digest" style="--sc:#60a5fa"><i class="fa-solid fa-chart-line" aria-hidden="true"></i><span>Lately</span></button>
++ <button type="button" role="tab" id="ha-tab-star" data-tab="star" aria-selected="false" aria-controls="home-star-section" tabindex="-1" style="--sc:#f59e0b"><i class="fa-solid fa-star" aria-hidden="true"></i><span>Star</span></button>
++ <button type="button" role="tab" id="ha-tab-happening" data-tab="happening" aria-selected="false" aria-controls="home-pulse-section" tabindex="-1" hidden style="--sc:#fbbf24"><i class="fa-solid fa-bolt" aria-hidden="true"></i><span>Happening</span></button>
++ </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>
++ <div class="home-digest" id="home-digest" data-panel="lately"><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="home-star-section" id="home-star-section" data-panel="star"><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()">
@@@ -139,12 +149,13 @@
</div>
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
-- <div class="home-section-lazy" id="home-pulse-section" style="display:none">
++ <div class="home-section-lazy" id="home-pulse-section" style="display:none" data-panel="happening">
<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>
++ </div><!-- /home-around -->
<!-- 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
@@@ -205,12 +216,20 @@
</div>
</div><!-- /home-catchup -->
-- <!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
-- why it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->
++ <!-- New here? (2026-10-30, the short-pages rule): the tour, the About banner and the network hub are
++ three introductions to the place. On a phone they share one place — a row of tabs and one of them at a
++ time. Wider screens hide the tabs and show all three. demoteHomeIntroSections() moves this whole
++ block to the end of the page once the reader has seen it often enough. -->
++ <div class="home-tabbox home-intro" id="home-intro" data-tab="tour">
++ <div class="home-tabs" role="tablist" aria-label="New here?">
++ <button type="button" role="tab" id="hi-tab-tour" data-tab="tour" aria-selected="true" aria-controls="home-tour-mount" style="--sc:#22d3ee"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i><span>How it works</span></button>
++ <button type="button" role="tab" id="hi-tab-about" data-tab="about" aria-selected="false" aria-controls="home-cta-banner" tabindex="-1" style="--sc:#f472b6"><i class="fa-solid fa-book-open" aria-hidden="true"></i><span>About</span></button>
++ <button type="button" role="tab" id="hi-tab-network" data-tab="network" aria-selected="false" aria-controls="home-network-hub" tabindex="-1" style="--sc:#38bdf8"><i class="fa-solid fa-network-wired" aria-hidden="true"></i><span>The network</span></button>
++ </div>
<!-- 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>
++ <div class="home-tour-mount home-section-lazy" id="home-tour-mount" data-panel="tour" data-fx-viewport></div>
<!-- Streaming CTA -->
-- <div class="home-cta-banner" id="home-cta-banner" data-fx-viewport>
++ <div class="home-cta-banner" id="home-cta-banner" data-panel="about" 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>
@@@ -358,11 +377,8 @@
</div><!-- /home-about-body -->
</div>
</div>
--
--</div>
--
--<div class="container">
-- <div class="network-hub">
++ <!-- The network: the fourteen services one account reaches. -->
++ <div class="network-hub" id="home-network-hub" data-panel="network">
<div class="network-hub-header">
<i class="fa-solid fa-network-wired"></i>
<div>
@@@ -493,7 +509,11 @@
</a>
</div>
</div>
++ </div><!-- /home-intro -->
++
++</div>
++<div class="container">
<!-- 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>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex b0259f6f..eba2f9b8 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1179,19 +1179,30 @@ function _homeIntroSeenCount() {
} catch { return 0; }
}
let _introSightingArmed = false;
++let _introCounted = false;
++const _introSeen = new Set();
++/**
++ * One sighting of the introductions: the reader has seen both of them during this visit.
++ *
++ * The tour and the About banner are two panels of the same "New here?" block now, and on a phone only one of
++ * them is on screen at a time — so the About banner is seen when its tab is opened (initHomeIntro), not only
++ * when it scrolls past.
++ */
++function _introSeenNow(id) {
++ if (_introCounted) return;
++ _introSeen.add(id);
++ if (_introSeen.size < 2) return;
++ _introCounted = true;
++ try { localStorage.setItem(_INTRO_SEEN_KEY, String(Math.min(_homeIntroSeenCount() + 1, 9999))); } catch { /* */ }
++}
function _watchIntroSighting() {
if (_introSightingArmed || !('IntersectionObserver' in window)) return;
const tour = document.getElementById('home-tour-mount');
const about = document.getElementById('home-cta-banner');
if (!tour || !about) return;
_introSightingArmed = true;
-- const seen = new Set();
const io = new IntersectionObserver((entries) => {
-- for (const en of entries) if (en.isIntersecting) seen.add(en.target.id);
-- if (seen.size === 2) {
-- io.disconnect();
-- try { localStorage.setItem(_INTRO_SEEN_KEY, String(Math.min(_homeIntroSeenCount() + 1, 9999))); } catch { /* */ }
-- }
++ for (const en of entries) if (en.isIntersecting) _introSeenNow(en.target.id);
}, { threshold: 0.25 });
io.observe(tour); io.observe(about);
}
@@@ -1199,19 +1210,31 @@ function _watchIntroSighting() {
/**
* Once both introductions have been seen enough times, they go to the very end of the page.
*
-- * They move as a pair, in order, after the changelog. The old version appended to the *first*
-- * .container on the home page — but the changelog lives in a later one, so "moved to the bottom"
-- * actually landed them in the middle of the page, above Recently Online.
++ * They are one block now — the tour, the About banner and the network hub behind one row of tabs — and
++ * they move as that block, in order, after the changelog. The old version appended to the *first* .container on
++ * the home page — but the changelog lives in a later one, so "moved to the bottom" actually landed them in the
++ * middle of the page, above Recently Online.
++ */
++/**
++ * The restream tour (public/js/home-tour.js, feature homeTour) sits below the fold: its code loads when the mount
++ * nears the viewport. The mount is a panel of the "New here?" block now, so it can be hidden while another tab is
++ * open — force=true loads it at once, which is what opening its tab does.
*/
--/** The restream tour (public/js/home-tour.js, feature homeTour) sits below the fold: its code loads when the mount nears the viewport. */
let _homeTourArmed = false;
--function _lazyHomeTour() {
++let _homeTourLoading = false;
++function _loadHomeTour() {
++ if (_homeTourLoading || !window.ov) return;
++ _homeTourLoading = true;
++ window.ov.load('homeTour').catch(() => { _homeTourLoading = false; _homeTourArmed = false; });
++}
++function _lazyHomeTour(force) {
const el = document.getElementById('home-tour-mount');
-- if (_homeTourArmed || !el || !window.ov) return;
++ if (!el || !window.ov) return;
++ if (force) { _loadHomeTour(); return; }
++ if (_homeTourArmed) return;
_homeTourArmed = true;
-- const go = () => window.ov.load('homeTour').catch(() => { _homeTourArmed = false; });
-- if (!('IntersectionObserver' in window)) return go();
-- const io = new IntersectionObserver((ents) => { if (ents.some((e) => e.isIntersecting)) { io.disconnect(); go(); } }, { rootMargin: '800px 0px' });
++ if (!('IntersectionObserver' in window)) return _loadHomeTour();
++ const io = new IntersectionObserver((ents) => { if (ents.some((e) => e.isIntersecting)) { io.disconnect(); _loadHomeTour(); } }, { rootMargin: '800px 0px' });
io.observe(el);
}
@@@ -1219,34 +1242,45 @@ function demoteHomeIntroSections() {
_lazyHomeTour();
_watchIntroSighting();
if (_homeIntroSeenCount() < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
++ const block = document.getElementById('home-intro');
++ if (!block || block.dataset.ovDemoted) return;
const containers = document.querySelectorAll('#page-home > .container, #page-home .container');
const changelog = document.getElementById('home-changelog-wrapper');
const last = (changelog && changelog.closest('.container')) || containers[containers.length - 1];
if (!last) return;
-- for (const id of ['home-tour-mount', 'home-cta-banner']) {
-- const el = document.getElementById(id);
-- if (!el || el.dataset.ovDemoted) continue;
-- el.dataset.ovDemoted = '1';
-- el.classList.add('home-intro-demoted');
-- try { last.appendChild(el); } catch { /* */ }
-- }
++ block.dataset.ovDemoted = '1';
++ block.classList.add('home-intro-demoted');
++ try { last.appendChild(block); } catch { /* */ }
}
/**
-- * Catch up: on a phone the five rails share one place, one rail at a time (the tabs and the CSS are in the home
-- * fragment and features/home.css). A tab shows once its rail has something: each loader already hides its header
-- * or section when it has nothing, so the tabs follow those. The person's last tab is kept for their next visit.
++ * Tabs for a block of home sections (2026-10-30, the owner's short-pages rule: a phone page is one or two screens).
++ * On a phone a block shows one of its sections at a time behind a row of tabs; wider screens never see the tabs
++ * and show every section. The tabs and the CSS live in the home fragment and features/home.css.
++ *
++ * initHomeTabs(boxId, shownIds, storageKey, onOpen)
++ * boxId the block's element id. It carries data-tab — the section that is open — and its tab row.
++ * shownIds tab key → the id of the element that says the section has something. Each loader already hides
++ * that element when its section is empty, so the tab goes with it.
++ * storageKey where the reader's last tab is kept for their next visit.
++ * onOpen called with the key of the section that just opened (the tour loads when its tab opens).
++ *
++ * A tab shows once its section has something; arrow keys, Home and End move between the tabs that show; the kept
++ * tab opens in the first few seconds only, so a section that arrives later never moves the page under the reader;
++ * a section that empties out falls back to one that has something, and a block with nothing left in it drops its
++ * tab row.
*/
--const HOME_CATCHUP_KEY = 'ov_home_catchup';
--const _HOME_CATCHUP_SHOWN = { vods: 'home-recent-vods-header', clips: 'home-clips-header', pastes: 'home-pastes-header', moments: 'home-moments-section' };
--function initHomeCatchup() {
-- const box = document.getElementById('home-catchup');
-- if (!box || box._ovCatchup) return;
-- box._ovCatchup = true;
-- const tabs = [...box.querySelectorAll('.home-catchup-tabs [role="tab"]')];
-- const has = (key) => { const el = _HOME_CATCHUP_SHOWN[key] && document.getElementById(_HOME_CATCHUP_SHOWN[key]); return !el || el.style.display !== 'none'; };
-- let wanted = 'recent';
-- try { wanted = localStorage.getItem(HOME_CATCHUP_KEY) || 'recent'; } catch { /* */ }
++function initHomeTabs(boxId, shownIds, storageKey, onOpen) {
++ const box = document.getElementById(boxId);
++ if (!box || box._ovTabs) return;
++ const list = box.querySelector('[role="tablist"]');
++ const tabs = list ? [...list.querySelectorAll('[role="tab"]')] : [];
++ if (!list || !tabs.length) return;
++ box._ovTabs = true;
++ const fallback = box.dataset.tab || tabs[0].dataset.tab;
++ const has = (key) => { const el = shownIds[key] && document.getElementById(shownIds[key]); return !el || el.style.display !== 'none'; };
++ let wanted = fallback;
++ try { wanted = localStorage.getItem(storageKey) || fallback; } catch { /* */ }
const pick = (key, { remember = false, focus = false } = {}) => {
box.dataset.tab = key;
for (const t of tabs) {
@@@ -1255,23 +1289,27 @@ function initHomeCatchup() {
t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
}
-- if (remember) { wanted = key; try { localStorage.setItem(HOME_CATCHUP_KEY, key); } catch { /* */ } }
++ if (remember) { wanted = key; try { localStorage.setItem(storageKey, key); } catch { /* */ } }
++ if (onOpen) { try { onOpen(key); } catch { /* */ } }
};
-- // The kept tab opens if its rail arrives in the first few seconds; later, a rail arriving never moves the page
-- // under the reader. A rail that empties out falls back to the first one.
++ // The kept tab opens if its section arrives in the first few seconds; later, a section arriving never moves the
++ // page under the reader. A section that empties out falls back to one that has something.
const settleBy = Date.now() + 3000;
const sync = () => {
for (const t of tabs) t.hidden = !has(t.dataset.tab);
++ const shown = tabs.filter((t) => !t.hidden);
++ list.hidden = !shown.length;
++ if (!shown.length) return;
const cur = box.dataset.tab;
-- if (!has(cur)) pick(has(wanted) ? wanted : 'recent');
++ if (!has(cur)) pick(shown[0].dataset.tab);
else if (cur !== wanted && has(wanted) && Date.now() < settleBy) pick(wanted);
};
-- box.querySelector('.home-catchup-tabs').addEventListener('click', (e) => {
++ list.addEventListener('click', (e) => {
const t = e.target.closest('[role="tab"]');
if (t) pick(t.dataset.tab, { remember: true });
});
// Arrow keys, Home and End move between the tabs that are showing (the tabs pattern).
-- box.querySelector('.home-catchup-tabs').addEventListener('keydown', (e) => {
++ list.addEventListener('keydown', (e) => {
const shown = tabs.filter((t) => !t.hidden);
const i = shown.indexOf(document.activeElement);
if (i < 0) return;
@@@ -1283,14 +1321,45 @@ function initHomeCatchup() {
t.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
const watch = new MutationObserver(sync);
-- for (const id of Object.values(_HOME_CATCHUP_SHOWN)) {
++ for (const id of Object.values(shownIds)) {
const el = document.getElementById(id);
if (el) watch.observe(el, { attributes: true, attributeFilter: ['style'] });
}
sync();
}
++/**
++ * Catch up: the five rails after Live Now share one place, one rail at a time. A tab shows once its rail has
++ * something: each loader already hides its header or section when it has nothing, so the tabs follow those.
++ */
++const HOME_CATCHUP_KEY = 'ov_home_catchup';
++const _HOME_CATCHUP_SHOWN = { vods: 'home-recent-vods-header', clips: 'home-clips-header', pastes: 'home-pastes-header', moments: 'home-moments-section' };
++function initHomeCatchup() {
++ initHomeTabs('home-catchup', _HOME_CATCHUP_SHOWN, HOME_CATCHUP_KEY);
++}
++
++/** Around OpenVibe: the digest, the star spotlight and the pulse share one place on a phone. */
++const HOME_AROUND_KEY = 'ov_home_around';
++const _HOME_AROUND_SHOWN = { lately: 'home-digest', star: 'home-star-section', happening: 'home-pulse-section' };
++function initHomeAround() {
++ initHomeTabs('home-around', _HOME_AROUND_SHOWN, HOME_AROUND_KEY);
++}
++
++/** New here?: the tour, the About banner and the network hub share one place on a phone. */
++const HOME_INTRO_KEY = 'ov_home_intro';
++const _HOME_INTRO_SHOWN = { tour: 'home-tour-mount', about: 'home-cta-banner', network: 'home-network-hub' };
++function initHomeIntro() {
++ initHomeTabs('home-intro', _HOME_INTRO_SHOWN, HOME_INTRO_KEY, (key) => {
++ // The tour's code loads when its mount nears the viewport; opening its tab is that moment.
++ if (key === 'tour') _lazyHomeTour(true);
++ // The About banner is behind a tab on a phone, so opening that tab is the reader seeing it.
++ if (key === 'about') _introSeenNow('home-cta-banner');
++ });
++}
++
async function loadHome() {
++ initHomeAround(); // the digest, the star spotlight and the pulse share one place on a phone
++ initHomeIntro(); // the tour, the About banner and the network hub share one place on a phone
initHomeCatchup();
demoteHomeIntroSections();
void loadHomeChangelog();
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex cb968269..8840aa43 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1234, // 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
-- jsBrotliKB: 290, // 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
++ jsRawKB: 1238, // 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
++ jsBrotliKB: 291, // 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
ddiff --git a/test/home-tabs.test.js b/test/home-tabs.test.js
nnew file mode 100644
iindex 00000000..87a396c0
---- /dev/null
++++ b/test/home-tabs.test.js
@@@ -0,0 +1,256 @@
++/**
++ * Home tabs (2026-10-30, the owner's short-pages rule: a phone page is one or two screens).
++ *
++ * Part 1 shipped Catch up: the five rails after Live Now share one place, a row of tabs and one rail at a time.
++ * This is the other two blocks, built on the same controller (initHomeTabs in public/js/app-home.js):
++ *
++ * Around OpenVibe the digest (Lately), the star spotlight (Star) and the pulse (Happening)
++ * New here? the restream tour (How it works), the About banner (About), the network hub (The network)
++ *
++ * Wider screens never see the tabs and show every section. Nothing was removed: every section is still in the
++ * page. The block is real here, not a string: the test opens the rendered home page in a DOM and drives it.
++ *
++ * node test/home-tabs.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++// linkedom is a declared dependency of openvibe-shared (it parses pages there too); resolve it through
++// that package so the test does not depend on npm hoisting it into this repo's node_modules.
++function loadLinkedom() {
++ try { return require('linkedom'); }
++ catch { return require(require.resolve('linkedom', { paths: [path.dirname(require.resolve('openvibe-shared/package.json'))] })); }
++}
++const { parseHTML } = loadLinkedom();
++
++const assets = require('../server/web/assets');
++try { assets.setSharedDir(require('openvibe-shared/files').dir); } catch { /* */ }
++const html = assets.renderRoute(assets.document('index.html').html, '/');
++const home = html.slice(html.indexOf('<section id="page-home"'), html.indexOf('</section>', html.indexOf('<section id="page-home"')));
++const css = fs.readFileSync(path.join(__dirname, '../public/css/features/home.css'), 'utf8');
++const js = fs.readFileSync(path.join(__dirname, '../public/js/app-home.js'), 'utf8');
++
++let failures = 0;
++function check(name, fn) {
++ try { const r = fn(); if (r && typeof r.then === 'function') return r.then(() => console.log(' \u2713', name), (e) => { failures++; console.log(' \u2717', name, '\n ', e.message); }); }
++ catch (e) { failures++; console.log(' \u2717', name, '\n ', e.message); return; }
++ console.log(' \u2713', name);
++}
++const tick = () => new Promise((r) => setTimeout(r, 0));
++// The loaders hide a section with el.style.display = 'none' and show it with ''. A browser turns that into
++// a mutation of the style attribute, which is what the controller watches; linkedom only reports attribute
++// writes, so the test writes the same attribute.
++const hide = (el) => el.setAttribute('style', 'display:none');
++const show = (el) => el.setAttribute('style', '');
++
++// ── the rendered home page in a DOM ────────────────────────────────────────────────────────────────
++/**
++ * The home page as the browser gets it, with the few globals the script needs, and the app-home.js
++ * functions loaded into it. views is how many times the reader has seen the introductions: at 18 the
++ * block moves to the end of the page.
++ */
++function openHome({ views = 0 } = {}) {
++ const { window } = parseHTML(`<!doctype html><html><body><section id="page-home">${home}</section></body></html>`);
++ const store = new Map();
++ Object.defineProperty(window, 'localStorage', {
++ configurable: true,
++ value: {
++ getItem: (k) => (store.has(k) ? store.get(k) : null),
++ setItem: (k, v) => { store.set(k, String(v)); },
++ removeItem: (k) => { store.delete(k); },
++ clear: () => store.clear(),
++ key: (i) => [...store.keys()][i] ?? null,
++ get length() { return store.size; },
++ },
++ });
++ if (views) store.set('ov_home_intro_seen', String(views));
++ window.matchMedia = () => ({ matches: true, addEventListener() { }, removeEventListener() { } });
++ window.requestAnimationFrame = (f) => setTimeout(f, 0);
++ window.cancelAnimationFrame = () => { };
++ window.scrollTo = () => { };
++ window.currentUser = null;
++ // The tour feature is loaded through the site's own loader; the test watches what it was asked for.
++ const loaded = [];
++ window.ov = { load: (name) => { loaded.push(name); return Promise.resolve(); } };
++ // Nothing scrolls into view in a test page, so the observers never fire on their own.
++ window.IntersectionObserver = class { constructor(cb) { this.cb = cb; } observe() { } unobserve() { } disconnect() { } };
++ window.Element.prototype.scrollIntoView = function () { };
++ const ctx = vm.createContext(window);
++ vm.runInContext(js, ctx, { filename: 'app-home.js' });
++ const api = {
++ window, ctx, store, loaded,
++ el: (id) => window.document.getElementById(id),
++ run: (code) => vm.runInContext(code, ctx),
++ click: (id) => window.document.getElementById(id).dispatchEvent(new window.Event('click', { bubbles: true })),
++ };
++ return api;
++}
++
++console.log('Home tabs');
++
++const AROUND = ['lately', 'star', 'happening'];
++const INTRO = ['tour', 'about', 'network'];
++const aroundStart = home.indexOf('<div class="home-tabbox" id="home-around"');
++const aroundEnd = home.indexOf('</div><!-- /home-around -->');
++const around = home.slice(aroundStart, aroundEnd);
++const introStart = home.indexOf('<div class="home-tabbox home-intro" id="home-intro"');
++const introEnd = home.indexOf('</div><!-- /home-intro -->');
++const intro = home.slice(introStart, introEnd);
++
++check('the three sections of Around OpenVibe share one block, in their order, with the egg where it was', () => {
++ assert.ok(aroundStart > 0 && aroundEnd > aroundStart, 'the block opens and closes on the home page');
++ const order = ['id="home-digest"', 'id="home-star-section"', 'id="hero-egg"', 'id="home-pulse-section"'].map((n) => around.indexOf(n));
++ assert.ok(order.every((i) => i > 0), 'every section is inside it');
++ assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'in the same order as before');
++ assert.ok(home.indexOf('id="home-around"') < home.indexOf('id="home-catchup"'), 'the block sits where the digest was');
++});
++
++check('one tab per section, named for screen readers, pointing at its section', () => {
++ assert.match(around, /<div class="home-tabs" role="tablist" aria-label="Around OpenVibe">/);
++ assert.ok(intro.includes('<div class="home-tabs" role="tablist" aria-label="New here?">'), 'the New here? tab list');
++ for (const [key, block, box] of [['lately', around, 'home-digest'], ['star', around, 'home-star-section'], ['happening', around, 'home-pulse-section'],
++ ['tour', intro, 'home-tour-mount'], ['about', intro, 'home-cta-banner'], ['network', intro, 'home-network-hub']]) {
++ const tab = (new RegExp(`<button type="button" role="tab" id="[a-z]+-tab-${key}" data-tab="${key}"[^>]*>`).exec(block) || [])[0];
++ assert.ok(tab, `the ${key} tab`);
++ const controls = (/aria-controls="([^"]+)"/.exec(tab) || [])[1];
++ assert.strictEqual(controls, box, `${key} controls its section`);
++ assert.ok(block.includes(`id="${controls}"`), `${controls} is inside the block`);
++ }
++});
++
++check('How it works is the default tab of New here? and Lately the default of Around OpenVibe', () => {
++ assert.match(intro, /id="hi-tab-tour" data-tab="tour" aria-selected="true"/);
++ assert.match(around, /id="ha-tab-lately" data-tab="lately" aria-selected="true"/);
++ assert.match(intro, /<div class="home-tabbox home-intro" id="home-intro" data-tab="tour">/);
++ assert.match(around, /<div class="home-tabbox" id="home-around" data-tab="lately">/);
++});
++
++check('the tabs and the one-section-at-a-time rule exist only under 760 px', () => {
++ assert.match(css, /\.home-tabs \{ display: none; \}/, 'no tabs by default');
++ const phone = css.slice(css.indexOf('.home-tabs { display: none; }'));
++ const block = phone.slice(phone.indexOf('@media (max-width: 759px) {'));
++ for (const key of [...AROUND, ...INTRO]) assert.ok(block.includes(`.home-tabbox:not([data-tab="${key}"]) [data-panel="${key}"]`), `${key} hides when another tab is open`);
++ assert.match(block, /min-height: 44px/, 'a tab is a 44 px target');
++ assert.match(js, /const _narrow = typeof window !== 'undefined' && window\.matchMedia\('\(max-width: 759px\)'\)/, 'the same breakpoint as the rails\' phone sizing');
++});
++
++check('the section\'s own heading goes while its tab names it, and the header keeps what it does', () => {
++ assert.ok(css.includes('[data-panel="star"] > .section-header > h2'), 'the star heading goes');
++ assert.ok(css.includes('[data-panel="lately"] .digest-title'), 'the digest title goes');
++ assert.ok(css.includes('[data-panel="network"] .network-hub-header h3'), 'the network heading goes');
++ assert.ok(css.includes('[data-panel="happening"] > .section-header { display: none; }'), 'the pulse header is only its heading');
++ assert.ok(css.includes('[data-panel="star"] > .section-header { margin: 6px 0 10px; min-height: 0; }'), 'the star header keeps its note');
++});
++
++check('a tab shows once its section does: the ids the script watches are the sections\' own', () => {
++ const aroundIds = /const _HOME_AROUND_SHOWN = \{([^}]+)\}/.exec(js);
++ const introIds = /const _HOME_INTRO_SHOWN = \{([^}]+)\}/.exec(js);
++ assert.ok(aroundIds && introIds, 'the maps of watched sections');
++ for (const id of aroundIds[1].match(/'([a-z-]+)'/g).map((s) => s.slice(1, -1))) assert.ok(around.includes(`id="${id}"`), `${id} is in the block`);
++ for (const id of introIds[1].match(/'([a-z-]+)'/g).map((s) => s.slice(1, -1))) assert.ok(intro.includes(`id="${id}"`), `${id} is in the block`);
++});
++
++check('the demotion moves the whole block, not the sections one by one', () => {
++ const demote = /function demoteHomeIntroSections\(\) \{([\s\S]*?)\n\}/.exec(js);
++ assert.ok(demote, 'the function');
++ assert.match(demote[1], /getElementById\('home-intro'\)/, 'it moves the block');
++ assert.match(demote[1], /last\.appendChild\(block\)/, 'it appends the block');
++ assert.ok(!/"home-tour-mount", "home-cta-banner"/.test(js), 'the two sections are no longer moved on their own');
++});
++
++check('the tour loads when its tab opens, not only when the mount nears the viewport', () => {
++ assert.match(js, /if \(key === 'tour'\) _lazyHomeTour\(true\);/, 'opening the tab loads the tour');
++ assert.match(js, /function _lazyHomeTour\(force\)/, 'the loader can be forced');
++});
++
++(async () => {
++ await check('a tab appears once its section has something (the loader shows the pulse)', async () => {
++ const p = openHome();
++ p.run('initHomeAround(); initHomeIntro();');
++ await tick();
++ assert.strictEqual(p.el('ha-tab-happening').hidden, true, 'the pulse has nothing yet, so its tab is hidden');
++ show(p.el('home-pulse-section'));
++ await tick();
++ assert.strictEqual(p.el('ha-tab-happening').hidden, false, 'the pulse arrived, so its tab is there');
++ assert.strictEqual(p.el('ha-tab-lately').hidden, false, 'the digest is there all along');
++ });
++
++ await check('a tab that is shown is one the reader can open, and it is remembered', async () => {
++ const p = openHome();
++ p.run('initHomeAround();');
++ await tick();
++ p.click('ha-tab-star');
++ await tick();
++ assert.strictEqual(p.el('home-around').dataset.tab, 'star', 'the block shows the star');
++ assert.strictEqual(p.el('ha-tab-star').getAttribute('aria-selected'), 'true', 'the tab says so');
++ assert.strictEqual(p.el('ha-tab-lately').getAttribute('aria-selected'), 'false', 'the other one does not');
++ assert.strictEqual(p.store.get('ov_home_around'), 'star', 'the reader keeps it for next time');
++ });
++
++ await check('a section that empties out falls back, and a block with nothing drops its tab row', async () => {
++ const p = openHome();
++ p.run('initHomeAround();');
++ await tick();
++ p.click('ha-tab-star');
++ await tick();
++ show(p.el('home-pulse-section')); // the pulse arrives
++ await tick();
++ hide(p.el('home-digest')); // the loader found nothing
++ hide(p.el('home-star-section'));
++ await tick();
++ assert.strictEqual(p.el('home-around').dataset.tab, 'happening', 'it fell back to the pulse');
++ hide(p.el('home-pulse-section'));
++ await tick();
++ assert.strictEqual(p.el('home-around').querySelector('[role="tablist"]').hidden, true, 'nothing left to show, so no tab row');
++ });
++
++ await check('the tour loads when its tab opens', async () => {
++ const p = openHome();
++ p.run('initHomeIntro();');
++ await tick();
++ assert.deepStrictEqual(p.loaded, [], 'nothing loads on its own in a page that never scrolls');
++ p.click('hi-tab-tour');
++ await tick();
++ assert.deepStrictEqual(p.loaded, ['homeTour'], 'opening the tab loads the tour');
++ });
++
++ await check('a first visit leaves the block where it is', async () => {
++ const p = openHome();
++ p.run('initHomeIntro(); demoteHomeIntroSections();');
++ await tick();
++ const block = p.el('home-intro');
++ assert.strictEqual(block.dataset.ovDemoted, undefined, 'it was not demoted');
++ const kids = [...block.parentElement.children];
++ assert.ok(kids.indexOf(p.el('home-catchup')) < kids.indexOf(block), 'it still comes after Catch up');
++ assert.notStrictEqual(block.parentElement, p.el('home-changelog-wrapper').parentElement, 'and before the changelog');
++ });
++
++ await check('after enough visits the whole block moves to the end of the page, and still works there', async () => {
++ const p = openHome({ views: 18 });
++ p.run('initHomeIntro(); demoteHomeIntroSections();');
++ await tick();
++ const block = p.el('home-intro');
++ assert.strictEqual(block.dataset.ovDemoted, '1', 'it was demoted');
++ assert.ok(block.classList.contains('home-intro-demoted'), 'and marked as such');
++ const container = p.el('home-changelog-wrapper').parentElement;
++ assert.strictEqual(block.parentElement, container, 'it moved into the changelog\'s container');
++ assert.strictEqual(container.lastElementChild, block, 'and to the end of it');
++ for (const id of ['home-tour-mount', 'home-cta-banner', 'home-network-hub']) {
++ assert.ok(block.contains(p.el(id)), `${id} travelled with it`);
++ }
++ // The tabs still work where it landed.
++ p.click('hi-tab-network');
++ await tick();
++ assert.strictEqual(block.dataset.tab, 'network', 'the tab opened its section');
++ assert.strictEqual(p.el('hi-tab-network').getAttribute('aria-selected'), 'true', 'and says so');
++ p.click('hi-tab-tour');
++ await tick();
++ assert.deepStrictEqual(p.loaded, ['homeTour'], 'the tour loads from its new place too');
++ });
++
++ console.log(failures ? `\n${failures} failed` : '\nall passed');
++ process.exit(failures ? 1 : 0);
++})();