openvibers/openvibe.live Commits

Home on phones: the five rails share one Catch up block with tabs (#461)

OP OpenVibers committed 14 days ago · a70653c6df · 7 files, +202 −24

On a phone (under 760 px), Recently Online, Recent VODs, Recent Clips, Recent
Pastes and AI Moments are one block: a row of tabs, one rail at a time. A tab
shows once its rail has something, the person's last tab is kept for their next
visit, and arrow keys move between the tabs. Wider screens never see the tabs.
The rails measured about 2,900 px at 390 px wide; the block is 260-1,065 px,
depending on the tab. The changelog preview shows the newest day only on a
phone. Nothing was removed, and every rail is still in the page in its order.

Changes

ddiff --git a/public/css/critical-selectors.json b/public/css/critical-selectors.json
iindex 22e3348f..719c9cd1 100644
---- a/public/css/critical-selectors.json
++++ b/public/css/critical-selectors.json
@@@ -15,6 +15,7 @@
"/docs"
],
"selectors": [
++ "#app",
"#openvibe-bell-mount",
"#page-home",
"#page-home .hero",
@@@ -81,12 +82,12 @@
".chat-badge",
".chat-badge i",
".chat-badge-admin",
++ ".chat-badge-streamer",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::before",
".chat-crossfeed-badge",
".chat-crossfeed-global",
".chat-crossfeed-stream",
-- ".chat-emote",
".chat-header",
".chat-header-actions",
".chat-input-area",
@@@ -100,8 +101,6 @@
".chat-msg .chat-user",
".chat-msg-crossfeed",
".chat-msg-hoverable .chat-reply-btn",
-- ".chat-msg-tts-off",
-- ".chat-msg-tts-off .chat-user::after",
".chat-msg.system",
".chat-nickels-float .rewards-coin-balance",
".chat-relay-badge",
@@@ -195,7 +194,6 @@
".hero-float--vod .hero-float-badge",
".hero-float-body",
".hero-float-inner",
-- ".hero-float-text",
".hero-golive-note",
".hero-golive-note a",
".hero-golive-note i",
@@@ -270,13 +268,13 @@
".muted",
".navbar",
".navbar > *",
++ ".navbar:not(.ovnav-top)",
".network-card-info",
".network-card-info span",
".network-card-info strong",
".network-hub-header > i",
".network-hub-header h3",
".network-hub-header p",
-- ".off-cyc-chip.active",
".off-cyc-title i",
".off-top-dur",
".off-top-rank",
@@@ -292,7 +290,7 @@
".openvibe-navbar.navbar",
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link",
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
-- ".openvibe-navbar.navbar .ovnav-launch",
++ ".openvibe-navbar.navbar.ovnav-top",
".ov-comet",
".ov-dot",
".ov-glow",
@@@ -392,14 +390,14 @@
"body .mobile-chat-toggle",
"body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .bucks-log-overlay, .user-ai-modal-overlay, .paste-modal-overlay, .vc-modal-overlay, .chat-slur-nudge-modal, .cosmetics-modal-overlay, .link-trust-overlay, .link-preview-overlay, .cgw-pop-overlay, .image-censor-overlay)",
"body.ov-nav-overlay .navbar.ovnav-top",
-- "body.ov-nav-overlay .openvibe-navbar.navbar.ovnav-top",
"body::after",
"body::before",
"button",
"html",
++ "html.ov-has-appbar #floating-chat-fab",
++ "html.ov-has-appbar body",
"img",
"input",
-- "#app",
"select",
"select.form-input",
"textarea"
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex cab7b30b..dd02e2ef 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -1266,3 +1266,40 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
/* Text links in a row: taller only, so neighbours never take each other's taps. */
#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. */
++.home-catchup-tabs { display: none; }
++@media (max-width: 759px) {
++ .home-catchup-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"] {
++ --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;
++ color: var(--text-secondary); background: transparent;
++ 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"] {
++ 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: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"],
++ .home-catchup:not([data-tab="pastes"]) [data-catchup="pastes"],
++ .home-catchup:not([data-tab="moments"]) [data-catchup="moments"] { display: none !important; }
++ .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; } }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 495b779c..a2ffd577 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -5915,8 +5915,10 @@ body.theme-light .btn-outline:hover {
pointer-events: none;
border-radius: 0 0 12px 12px;
}
++/* A phone shows the newest day only (2026-10-30, the short-pages rule): the hero and the footer already carry the
++ latest change, and "Show all" or "View all" opens the rest. */
@media (max-width: 560px) {
-- .home-changelog-wrapper.collapsed { max-height: 360px; }
++ .home-changelog-wrapper.collapsed { max-height: 200px; }
}
/* ═══════════════════════════════════════════════════════════════
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex 3f7d7ee3..4fbd2be8 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -149,49 +149,61 @@
<!-- What people made, above the introduction to the site (roadmap 32.7: the home leads with live
content and creators): channels that were just on, their recordings, the clips people took
and their pastes. What the AI made follows it (AI Moments; 33.6: made, then derived). -->
++ <!-- Catch up. On a phone the five rails below share one place: a row of tabs and one rail at a time, so the
++ home stays a couple of screens tall (the owner's short-pages rule, 2026-10-28). Wider screens hide the tabs and
++ show every rail. A tab shows once its rail has something (initHomeCatchup in app-home.js). -->
++ <div class="home-catchup" id="home-catchup" data-tab="recent">
++ <div class="home-catchup-tabs" role="tablist" aria-label="Catch up">
++ <button type="button" role="tab" id="hc-tab-recent" data-tab="recent" aria-selected="true" aria-controls="stream-grid-recent" style="--sc:#60a5fa"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i><span>Recently online</span></button>
++ <button type="button" role="tab" id="hc-tab-vods" data-tab="vods" aria-selected="false" aria-controls="home-recent-vods-grid" tabindex="-1" style="--sc:#a78bfa" hidden><i class="fa-solid fa-video" aria-hidden="true"></i><span>VODs</span></button>
++ <button type="button" role="tab" id="hc-tab-clips" data-tab="clips" aria-selected="false" aria-controls="home-clips-grid" tabindex="-1" style="--sc:#f472b6" hidden><i class="fa-solid fa-scissors" aria-hidden="true"></i><span>Clips</span></button>
++ <button type="button" role="tab" id="hc-tab-pastes" data-tab="pastes" aria-selected="false" aria-controls="home-pastes-list" tabindex="-1" style="--sc:#2dd4bf" hidden><i class="fa-solid fa-paste" aria-hidden="true"></i><span>Pastes</span></button>
++ <button type="button" role="tab" id="hc-tab-moments" data-tab="moments" aria-selected="false" aria-controls="home-moments-row" tabindex="-1" style="--sc:#22d3ee" hidden><i class="fa-solid fa-brain" aria-hidden="true"></i><span>AI Moments</span></button>
++ </div>
<!-- Recently Online -->
-- <div class="section-header" style="--sc:#60a5fa">
++ <div class="section-header" style="--sc:#60a5fa" data-catchup="recent">
<h2><i class="fa-solid fa-clock-rotate-left"></i> Recently Online<small class="section-kicker">just missed them</small></h2>
</div>
-- <div class="stream-grid" id="stream-grid-recent">
++ <div class="stream-grid" id="stream-grid-recent" data-catchup="recent">
<div class="empty-state">
<p class="muted">No recent streams</p>
</div>
</div>
-- <div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none"></div>
++ <div id="stream-grid-recent-pagination" class="pastes-pagination" style="display:none" data-catchup="recent"></div>
<!-- Recent VODs -->
-- <div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
++ <div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header" data-catchup="vods">
<h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
<a href="/content?type=vods" onclick="return handleLinkClick(event, '/content?type=vods')" class="section-action">View all</a>
</div>
-- <div class="stream-grid" id="home-recent-vods-grid"></div>
-- <div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
++ <div class="stream-grid" id="home-recent-vods-grid" data-catchup="vods"></div>
++ <div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none" data-catchup="vods"></div>
<!-- Recent Clips -->
-- <div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
++ <div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header" data-catchup="clips">
<h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
<a href="/content?type=clips" onclick="return handleLinkClick(event, '/content?type=clips')" class="section-action">View all</a>
</div>
-- <div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
-- <div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
++ <div class="stream-grid home-clips-grid" id="home-clips-grid" data-catchup="clips"></div>
++ <div id="home-clips-pagination" class="pastes-pagination" style="display:none" data-catchup="clips"></div>
<!-- Recent Pastes -->
-- <div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
++ <div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header" data-catchup="pastes">
<h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
<a href="/content?type=pastes" onclick="return handleLinkClick(event, '/content?type=pastes')" class="section-action">View all</a>
</div>
-- <div class="home-pastes-list" id="home-pastes-list"></div>
-- <div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
++ <div class="home-pastes-list" id="home-pastes-list" data-catchup="pastes"></div>
++ <div id="home-pastes-pagination" class="pastes-pagination" style="display:none" data-catchup="pastes"></div>
<!-- AI Moments showcase -->
-- <div class="home-section-lazy" id="home-moments-section" style="display:none">
++ <div class="home-section-lazy" id="home-moments-section" style="display:none" data-catchup="moments">
<div class="section-header" style="--sc:#22d3ee">
<h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
<a href="/moments" onclick="return handleLinkClick(event, '/moments')" class="section-action">See all</a>
</div>
<div class="moments-row ov-rail-track" id="home-moments-row"></div>
</div>
++ </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. -->
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 15118627..b0259f6f 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1232,7 +1232,66 @@ function demoteHomeIntroSections() {
}
}
++/**
++ * 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.
++ */
++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 { /* */ }
++ const pick = (key, { remember = false, focus = false } = {}) => {
++ box.dataset.tab = key;
++ for (const t of tabs) {
++ const on = t.dataset.tab === key;
++ t.setAttribute('aria-selected', on ? 'true' : 'false');
++ t.tabIndex = on ? 0 : -1;
++ if (on && focus) t.focus();
++ }
++ if (remember) { wanted = key; try { localStorage.setItem(HOME_CATCHUP_KEY, 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.
++ const settleBy = Date.now() + 3000;
++ const sync = () => {
++ for (const t of tabs) t.hidden = !has(t.dataset.tab);
++ const cur = box.dataset.tab;
++ if (!has(cur)) pick(has(wanted) ? wanted : 'recent');
++ else if (cur !== wanted && has(wanted) && Date.now() < settleBy) pick(wanted);
++ };
++ box.querySelector('.home-catchup-tabs').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) => {
++ const shown = tabs.filter((t) => !t.hidden);
++ const i = shown.indexOf(document.activeElement);
++ if (i < 0) return;
++ const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: shown.length - 1 }[e.key];
++ if (to == null) return;
++ e.preventDefault();
++ const t = shown[(to + shown.length) % shown.length];
++ pick(t.dataset.tab, { remember: true, focus: true });
++ t.scrollIntoView({ block: 'nearest', inline: 'nearest' });
++ });
++ const watch = new MutationObserver(sync);
++ for (const id of Object.values(_HOME_CATCHUP_SHOWN)) {
++ const el = document.getElementById(id);
++ if (el) watch.observe(el, { attributes: true, attributeFilter: ['style'] });
++ }
++ sync();
++}
++
async function loadHome() {
++ initHomeCatchup();
demoteHomeIntroSections();
void loadHomeChangelog();
_initHomeAbout();
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 4f58a487..cb968269 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1231, // 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: 289, // 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: 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)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
ddiff --git a/test/home-catchup.test.js b/test/home-catchup.test.js
nnew file mode 100644
iindex 00000000..835c9963
---- /dev/null
++++ b/test/home-catchup.test.js
@@@ -0,0 +1,70 @@
++/**
++ * Catch up (2026-10-30, the owner's short-pages rule: a phone page is one or two screens): on a phone the five rails
++ * after Live Now (Recently Online, Recent VODs, Recent Clips, Recent Pastes, AI Moments) share one place, a row of
++ * tabs and one rail at a time. Wider screens never see the tabs and show every rail. Nothing was removed: every
++ * rail is still in the page, in the same order.
++ *
++ * node test/home-catchup.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const assets = require('../server/web/assets');
++try { assets.setSharedDir(require('openvibe-shared/files').dir); } catch { /* */ }
++const 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 { fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(' ✗', name, '\n ', e.message); }
++}
++
++console.log('Home catch up');
++
++const start = home.indexOf('<div class="home-catchup" id="home-catchup" data-tab="recent">');
++const end = home.indexOf('</div><!-- /home-catchup -->');
++const box = home.slice(start, end);
++const TABS = ['recent', 'vods', 'clips', 'pastes', 'moments'];
++
++check('the five rails sit inside one Catch up block, in their order', () => {
++ assert.ok(start > 0 && end > start, 'the block opens and closes on the home page');
++ const order = ['id="stream-grid-recent"', 'id="home-recent-vods-grid"', 'id="home-clips-grid"', 'id="home-pastes-list"', 'id="home-moments-row"'].map((n) => box.indexOf(n));
++ assert.ok(order.every((i) => i > 0), 'every rail is inside it');
++ assert.deepStrictEqual([...order].sort((a, b) => a - b), order, 'in the same order as before');
++});
++
++check('one tab per rail, named for screen readers, pointing at its rail; only Recently online starts shown', () => {
++ assert.match(box, /<div class="home-catchup-tabs" role="tablist" aria-label="Catch up">/);
++ for (const key of TABS) {
++ const tab = (new RegExp(`<button type="button" role="tab" id="hc-tab-${key}" data-tab="${key}"[^>]*>`).exec(box) || [])[0];
++ assert.ok(tab, `the ${key} tab`);
++ const controls = (/aria-controls="([^"]+)"/.exec(tab) || [])[1];
++ assert.ok(controls && box.includes(`id="${controls}"`), `${key} controls a rail inside the block`);
++ assert.strictEqual(/ hidden\b/.test(tab), key !== 'recent', `${key} starts ${key === 'recent' ? 'shown' : 'hidden until its rail has something'}`);
++ assert.ok(new RegExp(`data-catchup="${key}"`).test(box), `${key}'s rail is marked`);
++ }
++});
++
++check('the tabs and the one-rail-at-a-time rule exist only under 760 px', () => {
++ assert.match(css, /\.home-catchup-tabs \{ display: none; \}/, 'no tabs by default');
++ const phone = css.slice(css.indexOf('.home-catchup-tabs { display: none; }'));
++ const block = phone.slice(phone.indexOf('@media (max-width: 759px) {'));
++ for (const key of TABS) assert.ok(block.includes(`.home-catchup:not([data-tab="${key}"]) [data-catchup="${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('a tab shows once its rail does: the ids the script watches are the rails\' own', () => {
++ const watched = /const _HOME_CATCHUP_SHOWN = \{([^}]+)\}/.exec(js);
++ assert.ok(watched, 'the map of watched elements');
++ for (const id of watched[1].match(/'([a-z-]+)'/g).map((s) => s.slice(1, -1))) assert.ok(box.includes(`id="${id}"`), `${id} is in the block`);
++ assert.match(js, /initHomeCatchup\(\);\s*\n\s*demoteHomeIntroSections\(\);/, 'loadHome starts it');
++});
++
++console.log(failures ? `\n${failures} failed` : '\nall passed');
++process.exit(failures ? 1 : 0);