openvibers/openvibe.live Commits

Catch up's rails load by themselves as you swipe; no Load more under a sideways row (#539)

OP OpenVibers committed 11 days ago · 25466cce14 · 4 files, +195 −3

The owner (2026-11-01): "load more is really dumb because it's like horizontal, so you can't even see it load in …
make it automatic, lazy load". On a phone Catch up's grids scroll sideways, and a button under the row added two cards
off screen at its far end. Now two placeholder cards sit at the end of the row, and when the reader swipes to within
about a card of them the next six load in their place (an IntersectionObserver on the rail). When all are in, the
row ends with a View all card. A grid that wraps (a wide screen) keeps its button.

Also: ov-density.js counted a sideways rail's cards as too many columns and clamped the grid to repeat(N, 1fr) with
!important, which squeezed the first cards to a few pixels (production's Recently Online showed an 81 px sliver).

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 8069f10a..29565396 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -1339,9 +1339,33 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none;
}
#home-catchup .stream-grid::-webkit-scrollbar { display: none; }
++ /* ov-density.js reads a sideways rail's implicit columns as "too many" and clamps the grid to repeat(N, minmax(0, 1fr))
++ with !important, which squeezed the first cards to a few pixels (production's Recently Online showed an 81 px
++ sliver, 2026-11-01). A rail has no columns to choose: its own track sizing wins. */
++ #home-catchup .stream-grid[data-ovd-cols] { grid-template-columns: none !important; }
#home-catchup .stream-grid > * { scroll-snap-align: start; min-width: 0; }
#home-catchup .stream-grid > .empty-state { grid-column: auto; min-width: calc(100vw - 32px); }
++ /* The end of a sideways rail (2026-11-01): placeholder cards where the next ones load by themselves as the reader
++ swipes toward them (app-home.js homeRailTail), then one "View all" card once every card is in. */
++ #home-catchup .stream-grid > .home-rail-tail { display: flex; }
++ .home-rail-skel {
++ flex-direction: column; gap: 10px; padding-bottom: 14px; overflow: hidden;
++ border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card);
++ animation: homeRailPulse 1.4s ease-in-out infinite alternate;
++ }
++ .home-rail-skel-thumb { aspect-ratio: 16 / 9; background: color-mix(in srgb, var(--text-primary) 7%, var(--bg-card)); }
++ .home-rail-skel-line { height: 12px; width: 72%; margin: 0 14px; border-radius: 6px; background: color-mix(in srgb, var(--text-primary) 9%, var(--bg-card)); }
++ .home-rail-skel-line.is-short { width: 44%; }
++ .home-rail-end {
++ flex-direction: column; align-items: center; justify-content: center; gap: 10px; align-self: stretch; min-height: 160px;
++ border-radius: 14px; border: 1px dashed var(--border); color: var(--text-secondary); font-weight: 700; text-decoration: none;
++ }
++ .home-rail-end:hover, .home-rail-end:focus-visible { color: var(--text-primary); border-color: var(--accent); }
++ .home-rail-end-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
}
++.home-rail-tail { display: none; }
++@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }
++@media (prefers-reduced-motion: reduce) { .home-rail-skel { animation: none; } }
@media (max-width: 759px) and (prefers-reduced-motion: reduce) { .home-tabs [role="tab"] { transition: none; } }
/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex d6839ff7..edd47c85 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -2185,7 +2185,7 @@ function homeRailRange(key, opts, gridId, loaderName) {
if (opts && opts.more) return { offset: rail.shown, limit: opts.more, append: true, rail };
rail.shown = 0;
const cols = _homeRailCols(rail.grid);
-- return { offset: 0, limit: Math.max(_narrow ? 2 : 4, cols * 2), append: false, rail };
++ return { offset: 0, limit: Math.max(4, cols * 2), append: false, rail };
}
/** Record what a load brought in, then fill the last row if it is short. */
function homeRailLoaded(rail, offset, count, total) {
@@@ -2210,6 +2210,59 @@ function homeRailAutofill(rail) {
if (typeof fn === 'function') fn({ more: need, auto: true }); else rail.filling = false;
}
++/** True when the grid is a row that scrolls sideways (features/home.css makes Catch up's grids one on a phone). */
++function homeRailSideways(grid) {
++ try { return !!grid && getComputedStyle(grid).overflowX === 'auto'; } catch { return false; }
++}
++/** Cards a sideways rail asks for at a time: a few swipes' worth. */
++const HOME_RAIL_BATCH = 6;
++/**
++ * The end of a sideways rail. While cards are left: two placeholder cards, and when the reader swipes to within about
++ * a card of them the next batch loads in their place (an IntersectionObserver on the rail itself, no scroll handler).
++ * When none are left: one "View all" card to the section's own page. left < 0 removes the tail (a grid that wraps
++ * keeps its button).
++ */
++function homeRailTail(rail, left) {
++ const grid = rail && rail.grid;
++ if (!grid) return;
++ for (const old of grid.querySelectorAll(':scope > .home-rail-tail')) old.remove();
++ if (rail.tailObserver) { rail.tailObserver.disconnect(); rail.tailObserver = null; }
++ if (left < 0) return;
++ if (left === 0) {
++ const all = grid.previousElementSibling && grid.previousElementSibling.querySelector('a.section-action[href]');
++ if (!all || !rail.shown) return;
++ const href = all.getAttribute('href');
++ const end = document.createElement('a');
++ end.className = 'home-rail-tail home-rail-end';
++ end.href = href;
++ end.addEventListener('click', (e) => handleLinkClick(e, href));
++ end.innerHTML = '<span class="home-rail-end-icon" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></span><span>View all</span>';
++ grid.appendChild(end);
++ return;
++ }
++ const skel = [];
++ for (let i = 0; i < Math.min(2, left); i++) {
++ const s = document.createElement('div');
++ s.className = 'home-rail-tail home-rail-skel';
++ s.setAttribute('aria-hidden', 'true');
++ s.innerHTML = '<div class="home-rail-skel-thumb"></div><div class="home-rail-skel-line"></div><div class="home-rail-skel-line is-short"></div>';
++ grid.appendChild(s);
++ skel.push(s);
++ }
++ if (typeof IntersectionObserver !== 'function') return;
++ const more = () => {
++ if (rail.loading || !rail.loader) return;
++ const fn = window[rail.loader];
++ if (typeof fn !== 'function') return;
++ rail.loading = true;
++ Promise.resolve(fn({ more: Math.min(left, HOME_RAIL_BATCH) })).catch(() => {}).finally(() => { rail.loading = false; });
++ };
++ // rootMargin on the right: the batch starts loading about a card before the placeholders come into view.
++ rail.tailObserver = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)) more(); },
++ { root: grid, rootMargin: '0px 80% 0px 0px', threshold: 0 });
++ rail.tailObserver.observe(skel[0]);
++}
++
// Thin wrapper for homepage section pagination — same visual style as renderVodsPagination.
/**
* Home rails get a "Load more" button, not a pager.
@@@ -2223,6 +2276,10 @@ function renderHomePagination(containerId, rail) {
const el = document.getElementById(containerId);
if (!el || !rail) return;
const left = Math.max(0, rail.total - rail.shown);
++ // A rail that scrolls sideways (Catch up on a phone) loads its next cards by itself as the reader swipes toward its
++ // end: a button under the row added cards off screen, at the far end, two at a time (the owner, 2026-11-01).
++ if (homeRailSideways(rail.grid)) { el.innerHTML = ''; el.style.display = 'none'; homeRailTail(rail, left); return; }
++ homeRailTail(rail, -1);
// These containers ship with an inline display:none — the old pager unhid them itself, and
// this one has to as well or the button renders into a hidden box and nobody ever sees it.
if (left <= 0) { el.innerHTML = ''; el.style.display = 'none'; return; }
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex ba3b31a8..c2174b97 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1256, // 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 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: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 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)
++ jsRawKB: 1258, // 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 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: 294.4, // 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
ddiff --git a/test/home-rail-autoload.test.js b/test/home-rail-autoload.test.js
nnew file mode 100644
iindex 00000000..ecd575e2
---- /dev/null
++++ b/test/home-rail-autoload.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++// The home's sideways rails load by themselves (the owner, 2026-11-01: "load more is really dumb because it's like
++// horizontal, so you can't even see it load in … make it automatic, lazy load"). On a phone Catch up's grids scroll
++// sideways (features/home.css); there a rail has no "Load more" button: two placeholder cards sit at its end, and when
++// the reader swipes to within about a card of them the next batch loads in their place. When none are left, the rail
++// ends with a "View all" card. A grid that wraps (a wide screen) keeps its button.
++// node test/home-rail-autoload.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { parseHTML } = require('linkedom');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'home.css'), 'utf8');
++
++/** One top-level function's source, by name (app-home.js is a browser script with no exports). */
++function fn(name) {
++ const at = src.indexOf(`function ${name}(`);
++ assert.ok(at >= 0, `${name} is in app-home.js`);
++ let depth = 0;
++ for (let i = src.indexOf('{', at); i < src.length; i++) {
++ if (src[i] === '{') depth++;
++ else if (src[i] === '}' && --depth === 0) return src.slice(at, i + 1);
++ }
++ throw new Error(`unbalanced ${name}`);
++}
++const batch = /const HOME_RAIL_BATCH = (\d+);/.exec(src);
++assert.ok(batch, 'the batch size is named');
++
++function page(overflowX) {
++ const { window, document } = parseHTML(`<!doctype html><html><body>
++<div class="section-header" id="vods-header"><h2>Recent VODs</h2><a href="/content?type=vods" class="section-action">View all</a></div>
++<div class="stream-grid" id="grid"><a class="stream-card">1</a><a class="stream-card">2</a><a class="stream-card">3</a><a class="stream-card">4</a></div>
++<div id="pager" style="display:none"></div></body></html>`);
++ const observed = [];
++ class IO {
++ constructor(cb, opts) { this.cb = cb; this.opts = opts; observed.push(this); }
++ observe(el) { this.el = el; }
++ disconnect() { this.off = true; }
++ fire() { this.cb([{ isIntersecting: true, target: this.el }]); }
++ }
++ const calls = [];
++ const ctx = {
++ window, document, IntersectionObserver: IO, Promise, Math, Number, String,
++ getComputedStyle: () => ({ overflowX }),
++ handleLinkClick: () => false,
++ _homeRailCols: () => 1,
++ _narrow: true,
++ };
++ ctx.window.loadHomeRecentVods = (o) => { calls.push(o); return Promise.resolve(); };
++ vm.createContext(ctx);
++ vm.runInContext(`const HOME_RAIL_BATCH = ${batch[1]};\n${fn('homeRailSideways')}\n${fn('homeRailTail')}\n${fn('renderHomePagination')}\nthis.renderHomePagination = renderHomePagination;`, ctx);
++ const rail = { grid: document.getElementById('grid'), loader: 'loadHomeRecentVods', shown: 4, total: 852 };
++ return { ctx, document, rail, observed, calls };
++}
++
++(async () => {
++ // A sideways rail: no button, two placeholders, and the next batch when the reader nears them.
++ {
++ const { ctx, document, rail, observed, calls } = page('auto');
++ ctx.renderHomePagination('pager', rail);
++ const pager = document.getElementById('pager');
++ assert.strictEqual(pager.style.display, 'none', 'a sideways rail has no Load more button');
++ assert.strictEqual(pager.innerHTML, '');
++ const skel = document.querySelectorAll('#grid > .home-rail-skel');
++ assert.strictEqual(skel.length, 2, 'two placeholder cards at the end');
++ assert.strictEqual(skel[0].getAttribute('aria-hidden'), 'true', 'placeholders are not read out');
++ assert.strictEqual(document.querySelector('#grid').lastElementChild.className, 'home-rail-tail home-rail-skel', 'they come after the cards');
++ const io = observed[observed.length - 1];
++ assert.strictEqual(io.opts.root, rail.grid, 'watched against the rail itself, not the page');
++ assert.match(io.opts.rootMargin, /^0px \d+% 0px 0px$/, 'a little ahead of the reader');
++ assert.strictEqual(io.el, skel[0]);
++ io.fire();
++ io.fire(); // a second sighting while the first batch loads asks for nothing more
++ assert.deepStrictEqual(calls.map((c) => c.more), [Number(batch[1])], 'one batch at a time');
++ await Promise.resolve(); await Promise.resolve();
++ // The loader appended its cards and rendered the pager again: the old tail goes, a new one follows the new cards.
++ rail.shown = 10;
++ ctx.renderHomePagination('pager', rail);
++ assert.strictEqual(document.querySelectorAll('#grid > .home-rail-tail').length, 2, 'one tail, never two');
++ assert.ok(observed[observed.length - 2].off, 'the old watcher stops');
++ // The last few: a batch never asks for more than are left.
++ rail.shown = 850;
++ ctx.renderHomePagination('pager', rail);
++ observed[observed.length - 1].fire();
++ assert.strictEqual(calls[calls.length - 1].more, 2);
++ assert.strictEqual(document.querySelectorAll('#grid > .home-rail-skel').length, 2);
++ // All in: no placeholders, and the rail ends with "View all" to the section's own page.
++ rail.shown = 852;
++ ctx.renderHomePagination('pager', rail);
++ assert.strictEqual(document.querySelectorAll('#grid > .home-rail-skel').length, 0);
++ const end = document.querySelector('#grid > .home-rail-end');
++ assert.ok(end && end.getAttribute('href') === '/content?type=vods' && /View all/.test(end.textContent), 'the end card is View all');
++ }
++ // A grid that wraps (a wide screen) keeps its button and gets no tail.
++ {
++ const { ctx, document, rail } = page('visible');
++ ctx.renderHomePagination('pager', rail);
++ assert.strictEqual(document.getElementById('pager').style.display, 'block');
++ assert.match(document.getElementById('pager').innerHTML, /class="home-load-more"/);
++ assert.strictEqual(document.querySelectorAll('#grid > .home-rail-tail').length, 0);
++ }
++ // The styles: the tail shows only in a sideways rail, the placeholders keep still for reduced motion, and the density
++ // control's column clamp never squeezes a rail's cards.
++ assert.match(css, /\.home-rail-tail \{ display: none; \}/);
++ assert.match(css, /#home-catchup \.stream-grid > \.home-rail-tail \{ display: flex; \}/);
++ assert.match(css, /@media \(prefers-reduced-motion: reduce\) \{ \.home-rail-skel \{ animation: none; \} \}/);
++ assert.match(css, /#home-catchup \.stream-grid\[data-ovd-cols\] \{ grid-template-columns: none !important; \}/);
++ console.log('home rail autoload: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });