openvibers/openvibe.live Commits
Home on phones, part 2: two more tabbed blocks (#462)
Changes
@@@ -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",@@@ -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; } }@@@ -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 livecontent 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>@@@ -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 phoneinitHomeCatchup();demoteHomeIntroSections();void loadHomeChangelog();@@@ -22,8 +22,8 @@ const BUDGETS = {htmlRawKB: 120, // measured 94.9htmlBrotliKB: 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)};nnew file mode 100644@@@ -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);++})();
@@ -85,6 +85,8 @@
@@ -85,6 +85,8 @@
".chat-badge-streamer",
".chat-badge-streamer",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::before",
".chat-badge[data-tip]::before",
+ ".chat-conn-status",
+ ".chat-conn-status[data-state=\"connected\"]",
".chat-crossfeed-badge",
".chat-crossfeed-badge",
".chat-crossfeed-global",
".chat-crossfeed-global",
".chat-crossfeed-stream",
".chat-crossfeed-stream",
@@ -189,7 +191,6 @@
@@ -189,7 +191,6 @@
".hero-float",
".hero-float",
".hero-float img",
".hero-float img",
".hero-float--clip .hero-float-badge",
".hero-float--clip .hero-float-badge",
- ".hero-float--moment .hero-float-badge",
".hero-float--paste .hero-float-badge",
".hero-float--paste .hero-float-badge",
".hero-float--vod .hero-float-badge",
".hero-float--vod .hero-float-badge",
".hero-float-body",
".hero-float-body",
@@ -292,6 +293,12 @@
@@ -292,6 +293,12 @@
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
".openvibe-navbar.navbar.ovnav-top",
".openvibe-navbar.navbar.ovnav-top",
".ov-comet",
".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-dot",
".ov-glow",
".ov-glow",
".ov-orbit",
".ov-orbit",
@@ -299,7 +306,6 @@
@@ -299,7 +306,6 @@
".ov-rail",
".ov-rail",
".ov-rail > .moments-row",
".ov-rail > .moments-row",
".ov-rail-bar",
".ov-rail-bar",
- ".ov-rail-bar > span",
".ov-rail-bar[hidden]",
".ov-rail-bar[hidden]",
".ov-rail-btn",
".ov-rail-btn",
".ov-rail-btn:disabled",
".ov-rail-btn:disabled",
@@ -307,7 +313,6 @@
@@ -307,7 +313,6 @@
".ov-rail-prev",
".ov-rail-prev",
".ov-rail-track",
".ov-rail-track",
".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-end) > .ov-rail-track",
".ov-rail:not(.is-scrollable) > .ov-rail-btn",
".ov-rail:not(.is-scrollable) > .ov-rail-btn",
".ov-rail:not(.is-start) > .ov-rail-track",
".ov-rail:not(.is-start) > .ov-rail-track",
@@ -348,7 +353,6 @@
@@ -348,7 +353,6 @@
".section-header h2 > i:first-child",
".section-header h2 > i:first-child",
".section-header h2 > i:first-child::before",
".section-header h2 > i:first-child::before",
".section-header::after",
".section-header::after",
- ".sgg-title i",
".stream-card",
".stream-card",
".stream-card-avatar",
".stream-card-avatar",
".stream-card-avatar img",
".stream-card-avatar img",
@@ -698,8 +698,9 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -698,8 +698,9 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-cta-features { grid-template-columns: 1fr; gap: 9px; }
.home-cta-features { grid-template-columns: 1fr; gap: 9px; }
.home-cta-feature { padding: 13px 14px; }
.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; }
.home-pastes-list { grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; }
@media (max-width: 640px) {
@media (max-width: 640px) {
.home-pastes-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.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; }
@@ -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; }
#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-catchup-tabs { display: none; }
+.home-tabs { display: none; }
@media (max-width: 759px) {
@media (max-width: 759px) {
- .home-catchup-tabs {
+ .home-catchup-tabs, .home-tabs {
display: flex; gap: 6px; margin: 30px -16px 6px; padding: 2px 16px 4px;
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;
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));
--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;
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;
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; }
@@ -1285,14 +1289,14 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
border: 1px solid var(--border); border-radius: 999px;
border: 1px solid var(--border); border-radius: 999px;
transition: color 0.18s, background 0.18s, border-color 0.18s;
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));
color: color-mix(in srgb, var(--sc-on) 30%, var(--text-primary));
background: color-mix(in srgb, var(--sc-on) 14%, transparent);
background: color-mix(in srgb, var(--sc-on) 14%, transparent);
border-color: color-mix(in srgb, var(--sc-on) 48%, 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="recent"]) [data-catchup="recent"],
.home-catchup:not([data-tab="vods"]) [data-catchup="vods"],
.home-catchup:not([data-tab="vods"]) [data-catchup="vods"],
.home-catchup:not([data-tab="clips"]) [data-catchup="clips"],
.home-catchup:not([data-tab="clips"]) [data-catchup="clips"],
@@ -1301,5 +1305,24 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -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], .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] > h2, .home-catchup [data-catchup] > .section-header > h2,
.home-catchup .section-header[data-catchup]::after, .home-catchup [data-catchup] > .section-header::after { display: none; }
.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; } }
@@ -101,11 +101,21 @@
@@ -101,11 +101,21 @@
</div>
</div>
</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 -->
<!-- "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) -->
<!-- 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">
<div class="hero-egg" id="hero-egg" style="display:none">
<button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
<button class="hero-egg-head" type="button" onclick="toggleHeroEgg()">
@@ -139,12 +149,13 @@
@@ -139,12 +149,13 @@
</div>
</div>
<!-- Community pulse: goals near completion, latest activity, weekly leaders -->
<!-- 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">
<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>
<h2><i class="fa-solid fa-bolt"></i> Happening Now<small class="section-kicker">the pulse</small></h2>
</div>
</div>
<div class="pulse-grid" id="pulse-grid"></div>
<div class="pulse-grid" id="pulse-grid"></div>
</div>
</div>
+ </div><!-- /home-around -->
<!-- What people made, above the introduction to the site (roadmap 32.7: the home leads with live
<!-- 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
content and creators): channels that were just on, their recordings, the clips people took
@@ -205,12 +216,20 @@
@@ -205,12 +216,20 @@
</div>
</div>
</div><!-- /home-catchup -->
</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) -->
<!-- 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 -->
<!-- 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-ambient" aria-hidden="true"></div>
<div class="cta-embers" aria-hidden="true">
<div class="cta-embers" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span>
@@ -358,11 +377,8 @@
@@ -358,11 +377,8 @@
</div><!-- /home-about-body -->
</div><!-- /home-about-body -->
</div>
</div>
</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">
<div class="network-hub-header">
<i class="fa-solid fa-network-wired"></i>
<i class="fa-solid fa-network-wired"></i>
<div>
<div>
@@ -493,7 +509,11 @@
@@ -493,7 +509,11 @@
</a>
</a>
</div>
</div>
</div>
</div>
+ </div><!-- /home-intro -->
+
+</div>
+<div class="container">
<!-- OpenVibe.Live Changelog -->
<!-- OpenVibe.Live Changelog -->
<div class="section-header" style="--sc:#94a3b8;margin-top:32px;">
<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>
<h2><i class="fa-solid fa-code-commit"></i> Recent Changes<small class="section-kicker">built in the open</small></h2>
@@ -1179,19 +1179,30 @@ function _homeIntroSeenCount() {
@@ -1179,19 +1179,30 @@ function _homeIntroSeenCount() {
} catch { return 0; }
} catch { return 0; }
}
}
let _introSightingArmed = false;
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() {
function _watchIntroSighting() {
if (_introSightingArmed || !('IntersectionObserver' in window)) return;
if (_introSightingArmed || !('IntersectionObserver' in window)) return;
const tour = document.getElementById('home-tour-mount');
const tour = document.getElementById('home-tour-mount');
const about = document.getElementById('home-cta-banner');
const about = document.getElementById('home-cta-banner');
if (!tour || !about) return;
if (!tour || !about) return;
_introSightingArmed = true;
_introSightingArmed = true;
- const seen = new Set();
const io = new IntersectionObserver((entries) => {
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 });
}, { threshold: 0.25 });
io.observe(tour); io.observe(about);
io.observe(tour); io.observe(about);
}
}
@@ -1199,19 +1210,31 @@ function _watchIntroSighting() {
@@ -1199,19 +1210,31 @@ function _watchIntroSighting() {
/**
/**
* Once both introductions have been seen enough times, they go to the very end of the page.
* 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;
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');
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;
_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);
io.observe(el);
}
}
@@ -1219,34 +1242,45 @@ function demoteHomeIntroSections() {
@@ -1219,34 +1242,45 @@ function demoteHomeIntroSections() {
_lazyHomeTour();
_lazyHomeTour();
_watchIntroSighting();
_watchIntroSighting();
if (_homeIntroSeenCount() < HOME_INTRO_VIEWS_BEFORE_DEMOTING) return;
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 containers = document.querySelectorAll('#page-home > .container, #page-home .container');
const changelog = document.getElementById('home-changelog-wrapper');
const changelog = document.getElementById('home-changelog-wrapper');
const last = (changelog && changelog.closest('.container')) || containers[containers.length - 1];
const last = (changelog && changelog.closest('.container')) || containers[containers.length - 1];
if (!last) return;
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 } = {}) => {
const pick = (key, { remember = false, focus = false } = {}) => {
box.dataset.tab = key;
box.dataset.tab = key;
for (const t of tabs) {
for (const t of tabs) {
@@ -1255,23 +1289,27 @@ function initHomeCatchup() {
@@ -1255,23 +1289,27 @@ function initHomeCatchup() {
t.tabIndex = on ? 0 : -1;
t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
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 settleBy = Date.now() + 3000;
const sync = () => {
const sync = () => {
for (const t of tabs) t.hidden = !has(t.dataset.tab);
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;
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);
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"]');
const t = e.target.closest('[role="tab"]');
if (t) pick(t.dataset.tab, { remember: true });
if (t) pick(t.dataset.tab, { remember: true });
});
});
// Arrow keys, Home and End move between the tabs that are showing (the tabs pattern).
// 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 shown = tabs.filter((t) => !t.hidden);
const i = shown.indexOf(document.activeElement);
const i = shown.indexOf(document.activeElement);
if (i < 0) return;
if (i < 0) return;
@@ -1283,14 +1321,45 @@ function initHomeCatchup() {
@@ -1283,14 +1321,45 @@ function initHomeCatchup() {
t.scrollIntoView({ block: 'nearest', inline: 'nearest' });
t.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
});
const watch = new MutationObserver(sync);
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);
const el = document.getElementById(id);
if (el) watch.observe(el, { attributes: true, attributeFilter: ['style'] });
if (el) watch.observe(el, { attributes: true, attributeFilter: ['style'] });
}
}
sync();
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() {
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();
initHomeCatchup();
demoteHomeIntroSections();
demoteHomeIntroSections();
void loadHomeChangelog();
void loadHomeChangelog();
@@ -22,8 +22,8 @@ const BUDGETS = {
@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
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)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,256 @@
@@ -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);
+})();