openvibers/openvibe.live Commits
Catch up's rails load by themselves as you swipe; no Load more under a sideways row (#539)
Changes
@@@ -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@@@ -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; }@@@ -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 beforehtmlBrotliKB: 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)};nnew file mode 100644@@@ -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); });
@@ -1339,9 +1339,33 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -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;
overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none;
}
}
#home-catchup .stream-grid::-webkit-scrollbar { display: 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 > * { scroll-snap-align: start; min-width: 0; }
#home-catchup .stream-grid > .empty-state { grid-column: auto; min-width: calc(100vw - 32px); }
#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; } }
@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
/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the
@@ -2185,7 +2185,7 @@ function homeRailRange(key, opts, gridId, loaderName) {
@@ -2185,7 +2185,7 @@ function homeRailRange(key, opts, gridId, loaderName) {
if (opts && opts.more) return { offset: rail.shown, limit: opts.more, append: true, rail };
if (opts && opts.more) return { offset: rail.shown, limit: opts.more, append: true, rail };
rail.shown = 0;
rail.shown = 0;
const cols = _homeRailCols(rail.grid);
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. */
/** Record what a load brought in, then fill the last row if it is short. */
function homeRailLoaded(rail, offset, count, total) {
function homeRailLoaded(rail, offset, count, total) {
@@ -2210,6 +2210,59 @@ function homeRailAutofill(rail) {
@@ -2210,6 +2210,59 @@ function homeRailAutofill(rail) {
if (typeof fn === 'function') fn({ more: need, auto: true }); else rail.filling = false;
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.
// Thin wrapper for homepage section pagination — same visual style as renderVodsPagination.
/**
/**
* Home rails get a "Load more" button, not a pager.
* Home rails get a "Load more" button, not a pager.
@@ -2223,6 +2276,10 @@ function renderHomePagination(containerId, rail) {
@@ -2223,6 +2276,10 @@ function renderHomePagination(containerId, rail) {
const el = document.getElementById(containerId);
const el = document.getElementById(containerId);
if (!el || !rail) return;
if (!el || !rail) return;
const left = Math.max(0, rail.total - rail.shown);
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
// 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.
// 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; }
if (left <= 0) { el.innerHTML = ''; el.style.display = 'none'; return; }
@@ -22,8 +22,8 @@ const BUDGETS = {
@@ -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
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,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
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)
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,111 @@
@@ -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); });