openvibers/openvibe.live Commits
Home on phones: the five rails share one Catch up block with tabs (#461)
Changes
@@@ -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"@@@ -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; } }@@@ -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; }}/* ═══════════════════════════════════════════════════════════════@@@ -149,49 +149,61 @@<!-- 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 tookand 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), thenwhy it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->@@@ -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();@@@ -22,8 +22,8 @@ const BUDGETS = {htmlRawKB: 120, // measured 94.9htmlBrotliKB: 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)};nnew file mode 100644@@@ -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);
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
"/docs"
"/docs"
],
],
"selectors": [
"selectors": [
+ "#app",
"#openvibe-bell-mount",
"#openvibe-bell-mount",
"#page-home",
"#page-home",
"#page-home .hero",
"#page-home .hero",
@@ -81,12 +82,12 @@
@@ -81,12 +82,12 @@
".chat-badge",
".chat-badge",
".chat-badge i",
".chat-badge i",
".chat-badge-admin",
".chat-badge-admin",
+ ".chat-badge-streamer",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::after",
".chat-badge[data-tip]::before",
".chat-badge[data-tip]::before",
".chat-crossfeed-badge",
".chat-crossfeed-badge",
".chat-crossfeed-global",
".chat-crossfeed-global",
".chat-crossfeed-stream",
".chat-crossfeed-stream",
- ".chat-emote",
".chat-header",
".chat-header",
".chat-header-actions",
".chat-header-actions",
".chat-input-area",
".chat-input-area",
@@ -100,8 +101,6 @@
@@ -100,8 +101,6 @@
".chat-msg .chat-user",
".chat-msg .chat-user",
".chat-msg-crossfeed",
".chat-msg-crossfeed",
".chat-msg-hoverable .chat-reply-btn",
".chat-msg-hoverable .chat-reply-btn",
- ".chat-msg-tts-off",
- ".chat-msg-tts-off .chat-user::after",
".chat-msg.system",
".chat-msg.system",
".chat-nickels-float .rewards-coin-balance",
".chat-nickels-float .rewards-coin-balance",
".chat-relay-badge",
".chat-relay-badge",
@@ -195,7 +194,6 @@
@@ -195,7 +194,6 @@
".hero-float--vod .hero-float-badge",
".hero-float--vod .hero-float-badge",
".hero-float-body",
".hero-float-body",
".hero-float-inner",
".hero-float-inner",
- ".hero-float-text",
".hero-golive-note",
".hero-golive-note",
".hero-golive-note a",
".hero-golive-note a",
".hero-golive-note i",
".hero-golive-note i",
@@ -270,13 +268,13 @@
@@ -270,13 +268,13 @@
".muted",
".muted",
".navbar",
".navbar",
".navbar > *",
".navbar > *",
+ ".navbar:not(.ovnav-top)",
".network-card-info",
".network-card-info",
".network-card-info span",
".network-card-info span",
".network-card-info strong",
".network-card-info strong",
".network-hub-header > i",
".network-hub-header > i",
".network-hub-header h3",
".network-hub-header h3",
".network-hub-header p",
".network-hub-header p",
- ".off-cyc-chip.active",
".off-cyc-title i",
".off-cyc-title i",
".off-top-dur",
".off-top-dur",
".off-top-rank",
".off-top-rank",
@@ -292,7 +290,7 @@
@@ -292,7 +290,7 @@
".openvibe-navbar.navbar",
".openvibe-navbar.navbar",
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link",
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link",
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
".openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active",
- ".openvibe-navbar.navbar .ovnav-launch",
+ ".openvibe-navbar.navbar.ovnav-top",
".ov-comet",
".ov-comet",
".ov-dot",
".ov-dot",
".ov-glow",
".ov-glow",
@@ -392,14 +390,14 @@
@@ -392,14 +390,14 @@
"body .mobile-chat-toggle",
"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 :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 .navbar.ovnav-top",
- "body.ov-nav-overlay .openvibe-navbar.navbar.ovnav-top",
"body::after",
"body::after",
"body::before",
"body::before",
"button",
"button",
"html",
"html",
+ "html.ov-has-appbar #floating-chat-fab",
+ "html.ov-has-appbar body",
"img",
"img",
"input",
"input",
- "#app",
"select",
"select",
"select.form-input",
"select.form-input",
"textarea"
"textarea"
@@ -1266,3 +1266,40 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -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. */
/* 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; }
#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; } }
@@ -5915,8 +5915,10 @@ body.theme-light .btn-outline:hover {
@@ -5915,8 +5915,10 @@ body.theme-light .btn-outline:hover {
pointer-events: none;
pointer-events: none;
border-radius: 0 0 12px 12px;
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) {
@media (max-width: 560px) {
- .home-changelog-wrapper.collapsed { max-height: 360px; }
+ .home-changelog-wrapper.collapsed { max-height: 200px; }
}
}
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
@@ -149,49 +149,61 @@
@@ -149,49 +149,61 @@
<!-- 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
and their pastes. What the AI made follows it (AI Moments; 33.6: made, then derived). -->
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 -->
<!-- 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>
<h2><i class="fa-solid fa-clock-rotate-left"></i> Recently Online<small class="section-kicker">just missed them</small></h2>
</div>
</div>
- <div class="stream-grid" id="stream-grid-recent">
+ <div class="stream-grid" id="stream-grid-recent" data-catchup="recent">
<div class="empty-state">
<div class="empty-state">
<p class="muted">No recent streams</p>
<p class="muted">No recent streams</p>
</div>
</div>
</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 -->
<!-- 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>
<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>
<a href="/content?type=vods" onclick="return handleLinkClick(event, '/content?type=vods')" class="section-action">View all</a>
</div>
</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 -->
<!-- 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>
<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>
<a href="/content?type=clips" onclick="return handleLinkClick(event, '/content?type=clips')" class="section-action">View all</a>
</div>
</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 -->
<!-- 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>
<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>
<a href="/content?type=pastes" onclick="return handleLinkClick(event, '/content?type=pastes')" class="section-action">View all</a>
</div>
</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 -->
<!-- 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">
<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>
<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>
<a href="/moments" onclick="return handleLinkClick(event, '/moments')" class="section-action">See all</a>
</div>
</div>
<div class="moments-row ov-rail-track" id="home-moments-row"></div>
<div class="moments-row ov-rail-track" id="home-moments-row"></div>
</div>
</div>
+ </div><!-- /home-catchup -->
<!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
<!-- The two introductions travel together: what OpenVibe does (the restream diagram), then
why it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->
why it exists (the About banner). demoteHomeIntroSections() moves them as a pair. -->
@@ -1232,7 +1232,66 @@ function demoteHomeIntroSections() {
@@ -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() {
async function loadHome() {
+ initHomeCatchup();
demoteHomeIntroSections();
demoteHomeIntroSections();
void loadHomeChangelog();
void loadHomeChangelog();
_initHomeAbout();
_initHomeAbout();
@@ -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: 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)
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,70 @@
@@ -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);