openvibers/openvibe.live Commits
Live: direct visits paint the right page and stop jumping (phone CLS: channel 1.31 → 0.01, clip 0.99 → 0.03, VOD 0.43 → 0.004, home 0.13 → 0.01) (#114)
Changes
@@@ -152,6 +152,32 @@ used 436 of its selectors, 17–18 % of the file.with the full file, just after the first paint. `test/critical-css.test.js` fails when more than a tenth of the listedselectors are no longer in `style.css`.++## Measurement 2026-10-12: layout shift on direct visits++++With the first paint earlier, what moved afterwards showed: on a phone a direct visit to a channel scored CLS 1.31, a++clip 0.99, a VOD 0.43 and the home page 0.13 (Chrome's "good" line is 0.1). Each had the same shape: the shell painted++placeholder markup, then the data, and then several parts that load on their own, each pushing the page down.++++- **The right page from the start.** The shell marked home active, so a channel visit painted the home page until the++ router swapped. `renderRoute` now moves `active` to the route's own section (`activePage` in `server/web/assets.js`),++ and `app.js` starts `currentPage` from it, so its first `showPage()` is not a change and does not fade the page in again.++- **Channel** (`_channelReady` in `app-channel.js`): until the data (`ch-data`) the header's placeholder words++ ("Channel", "0 followers") are outlines; until the first render and, offline, the "Most watched" block and the discover++ board (`ch-ready`, at most 2.5 s after the data) the tabs are out of the layout and the page keeps a full viewport, so++ the footer never shows and climbs back down.++- **VOD and clip** (`_mediaReady` in `app-media.js`, `mp-data`/`mp-ready`): the same outlines; the AI timeline, the++ activity strip, the source card, the clips and the comments appear together. `.vod-player-container` keeps a 16:9 box++ (`max-height: 70vh`, like the video) before the video knows its size; a taller video still grows it.++- **Home hero:** the countdown line ships as ` ` instead of `hidden`; the "shipped" line is no longer `hidden`++ (shipped.js collapsed it, then refilled it) and keeps its 33 px while empty; the stats board's reserved heights now++ include the "Show all" button (256 / 204 / 120 px, phone / tablet / wide).++- The cosmetics modal and the messenger panel are built by scripts whose stylesheets load without blocking; until those++ land the shell's inline style keeps them closed and the messenger toggle in its corner.++++Same A/B harness (phone profile, worst of two runs): channel 1.31 → 0.012, clip 0.99 → 0.025, VOD 0.43 → 0.004, home++0.127 → 0.011; at 1366 px channel 0.09 → 0.015, VOD 0.19 → 0.001, home 0.06 → 0.01. First paint unchanged.++`test/first-paint-stability.test.js` holds the pieces in place.++## Budgets`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly@@@ -1127,6 +1127,14 @@.ch-actions { flex-shrink: 0; }.channel-below-fold { padding-top: 6px; }++/* Before the channel's data (app-channel.js _channelReady): the page keeps a full viewport (below) so the footer stays++ under the fold, the tabs stay out of the layout until what lands above them is in, and the header shows outlines++ rather than its placeholder words ("Channel", "0 followers"). */++#page-channel:not(.ch-ready) .channel-below-fold { display: none; }++#page-channel:not(.ch-data) :is(#ch-display-name-offline, #ch-username-offline, #ch-follower-count-offline, #ch-category-badge-offline) {++ width: fit-content; color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-color: transparent; border-radius: 6px;++}++#page-channel:not(.ch-data) #ch-avatar-offline { color: transparent; }.ch-analytics-period-btns { display:flex; gap:4px; margin-left:auto; }.analytics-stat-cards {display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr));@@@ -1541,6 +1549,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }/* The channel page ends where its content ends: a full-viewport minimum left a screen of empty space above the footerwhenever the open tab was short. */#page-channel.page { min-height: 0; }++/* …except before its data, while it is the placeholder markup (see _channelReady). */++#page-channel.page:not(.ch-ready) { min-height: 100vh; }/* The offline area: one gutter for everything in its main column (the discover board sat flush with the edge). */.ch-offline-area .stream-main > #ch-discover { padding: 0 14px; box-sizing: border-box; }@@@ -226,6 +226,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }max-width: min(640px, 90vw); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }++/* Until shipped.js fills it, the line keeps its height without showing an empty pill, so the stats below stay put. */++.hero-latest:empty { visibility: hidden; min-height: 33px; }.pulse-card {display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);background: var(--bg-secondary); border: 1px solid var(--border);@@@ -1005,9 +1007,10 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }label under it and the delta pill on a third line: ~80px tall cards that were mostly air.Now: one 9-across row on wide screens (icon, number, label, short delta), three horizontalrows on tablets (icon · number+label · delta right), three tight columns on phones. The-- reserved min-height matches each layout so nothing jumps while the numbers load. */++ reserved min-height matches each layout, "Show all" button included (measured 2026-10-12 at++ 360–1920 px), so nothing jumps while the numbers load. */.hero-latest { margin-top: 18px; }--.hero-stats { margin: 16px 0 10px; gap: 10px; min-height: 108px; }++.hero-stats { margin: 16px 0 10px; gap: 10px; min-height: 120px; }.hero-stat-strip {display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); grid-auto-rows: auto;gap: 8px; max-width: 1240px; margin: 0 auto; width: 100%; padding: 0;@@@ -1040,7 +1043,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.hero-stat-strip .hero-stat-chart-hint { font-size: 0.5rem; margin-left: 2px; }/* Tablets and small laptops: three horizontal rows — icon · number + label · delta on the right. */@media (max-width: 1279px) {-- .hero-stats { min-height: 176px; }++ .hero-stats { min-height: 204px; }.hero-stat-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; max-width: 960px; }.hero-stat-strip .hero-stat { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: auto; padding: 8px 12px; }.hero-stat-strip .hero-stat > i { grid-row: 1; }@@@ -1052,7 +1055,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }}/* Phones: three tight columns, number over label, short pill. */@media (max-width: 699px) {-- .hero-stats { min-height: 214px; margin-top: 14px; }++ .hero-stats { min-height: 256px; margin-top: 14px; }.hero-stat-strip { gap: 6px; }.hero-stat-strip .hero-stat { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; padding: 7px 8px; column-gap: 6px; row-gap: 2px; border-radius: 10px; }.hero-stat-strip .hero-stat > i { grid-row: 1 / 3; width: 24px; height: 24px; font-size: 0.66rem; border-radius: 7px; }@@@ -108,6 +108,15 @@border-radius: 0; display: block;}.vod-player-info { margin-bottom: 24px; }++/* Before a VOD's or clip's data (app-media.js _mediaReady): outlines for the placeholder words, and the parts that++ arrive on their own (AI timeline, activity strip, clips, comments) stay out of the layout until they are all in. */++:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #vp-streamer, #vp-duration, #vp-views, #clp-title, #clp-streamer, #clp-duration, #clp-views) {++ color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-radius: 6px;++}++:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #clp-title) { width: fit-content; }++:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-avatar, #clp-avatar, .vod-player-meta i) { visibility: hidden; }++#page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }++#page-clip-player:not(.mp-ready) #clp-comments { display: none; }.vod-player-info {background: rgba(22,22,26,0.74);border: 1px solid rgba(255,255,255,0.05);@@@ -2963,6 +2963,9 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }position: relative; width: 100%;background: #000; border-radius: 0;overflow: hidden;++ /* The usual shape before the video knows its own: a taller video still grows the box (aspect-ratio is a minimum++ for content), and nothing below jumps when a 16:9 one arrives. */++ aspect-ratio: 16 / 9; max-height: 70vh;}.vod-player-container:hover .video-overlay { opacity: 1; }.vod-player-container.paused .video-overlay { opacity: 1; }@@@ -101,6 +101,10 @@.ov-route-error>div{flex:1;min-width:180px;display:grid;gap:2px}.ov-route-error span{color:var(--text-muted,#8b93ad);font-size:.9rem}@media (prefers-reduced-motion:reduce){.ov-route-skel span{animation:none}}++ /* Widgets their scripts build before cosmetics.css / messenger.css land (both load without blocking): closed, they++ stay out of the page instead of painting unstyled under the footer, and the toggle sits where it will. */++ .cosmetics-modal-overlay:not(.active),.messenger-panel:not(.open){display:none}++ .messenger-toggle{position:fixed;bottom:20px;left:20px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent-strong,#3472d8);color:var(--on-accent-strong,#fff)}</style><!-- Stylesheets.@@@ -190,7 +194,8 @@every few seconds would be read out to screen-reader users over and over. --><p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate visible" id="hero-rotate">stealth campers</span></span></p><p class="hero-quip visible" id="hero-quip">No investors. No suits. Just vibes.</p>-- <div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>++ <!-- Holds its line from the first paint (the countdown fills it with the page's data), so the buttons below do not move. -->++ <div class="hero-slogan-timer" id="hero-slogan-timer"> </div><div class="hero-cta" data-fx-viewport><button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button><a href="https://openvibe.tools" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>@@@ -221,7 +226,7 @@</div><!-- Newest shipped commit — built in the open, receipts included. Sits under thephilosophy row so the proof is next to the promise. -->-- <a class="hero-latest" id="hero-latest" data-ov-shipped="latest" data-service="live" href="/updates" onclick="return handleLinkClick(event, '/updates')" hidden></a>++ <a class="hero-latest" id="hero-latest" data-ov-shipped="latest" data-service="live" href="/updates" onclick="return handleLinkClick(event, '/updates')"></a><div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div></div></div>@@@ -602,13 +602,26 @@ function _watchTabsClip() {upd();}++// A direct visit paints the channel's markup before its data. Until the data (ch-data) channel.css outlines the++// header's placeholder words; until the first render, and offline until the "Most watched" block and the discover++// board that land above the tabs (ch-ready), it keeps the tabs out of the layout and the page at full height, so the++// data arriving pushes nothing on screen down. Never longer than 2.5 s after the data.++let _channelReadyTimer = null;++function _channelReady(ready = true) {++ const page = document.getElementById('page-channel');++ if (page) { page.classList.toggle('ch-ready', ready); if (!ready) page.classList.remove('ch-data'); }++ clearTimeout(_channelReadyTimer);++}++async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {_restoreChannelChat();_watchTabsClip();++ let offlineBlocks = null;try {const isNewChannel = currentChannelUsername !== username;currentChannelUsername = username;if (isNewChannel) {++ _channelReady(false);channelVodsPageByUser[username] = 1;channelClipsPageByUser[username] = 1;// Reset VOD filter/sort state for fresh channel navigation@@@ -632,6 +645,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIconst data = await api(`/streams/channel/${username}?vodLimit=${CHANNEL_VODS_PAGE_SIZE}&vodOffset=${channelVodOffset}&clipLimit=${CHANNEL_CLIPS_PAGE_SIZE}&clipOffset=${channelClipOffset}${initialVodExtra}`);const ch = data.channel;++ { const page = document.getElementById('page-channel'); if (page) page.classList.add('ch-data'); if (page && !page.classList.contains('ch-ready')) _channelReadyTimer = setTimeout(_channelReady, 2500); }// The tab says whose channel this is, as the server-rendered <title> did (a live stream// replaces it with the stream's title in activateChannelStream).if (ch && ch.username) setPageTitle(`${ch.display_name || ch.username} (@${ch.username})`);@@@ -809,7 +823,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIsetupBanBtn(document.getElementById('ch-btn-ban-offline'));// Customizable offline screen (image / video / custom HTML)-- _renderOfflineScreen(ch);++ offlineBlocks = _renderOfflineScreen(ch);// Offline: join the streamer's PERSISTENT chat room (not global) so// viewers can keep chatting + see history while the streamer is offline.@@@ -850,8 +864,10 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI});// Analytics is loaded lazily when its tab is first opened (see switchChannelTab).++ Promise.resolve(offlineBlocks).catch(() => {}).then(() => _channelReady());} catch (e) {++ _channelReady();console.error('Channel load error:', e);toast('Channel not found', 'error');navigate('/');@@@ -2919,6 +2935,7 @@ function _wireAboutDrag() {// Render a streamer's customizable offline screen into #ch-offline-screen.// image/video → served asset; html → sandboxed iframe (no same-origin, so the// streamer's markup can't touch viewers' session). Falls back to a tasteful default.++// Resolves once the offline blocks that land above the tabs ("Most watched", the discover board) are in.function _renderOfflineScreen(ch) {const host = document.getElementById('ch-offline-screen');if (!host) return;@@@ -2935,7 +2952,7 @@ function _renderOfflineScreen(ch) {<div class="ch-offline-explore ch-offline-explore--over" id="ch-offline-explore"></div></div><button class="ch-offline-reopen" id="ch-offline-reopen" onclick="_reopenOfflineOverlay()" title="Show most-watched content"><i class="fa-solid fa-fire"></i> Top content</button>`;-- _fillOfflineExplore(ch && ch.username);++ return _fillOfflineExplore(ch && ch.username);} else if (type === 'video' && url) {host.innerHTML = `<video class="ch-offline-media" src="${esc(url)}" autoplay muted loop playsinline></video>`;} else if (type === 'html' && (ch.offline_html || ch.offline_css)) {@@@ -2962,7 +2979,7 @@ function _renderOfflineScreen(ch) {<div class="ch-offline-default-sub">is offline — explore their top content, or say hi in chat</div><div class="ch-offline-explore" id="ch-offline-explore"></div></div>`;-- _fillOfflineExplore(ch && ch.username);++ return _fillOfflineExplore(ch && ch.username);}}@@@ -3037,7 +3054,7 @@ async function _fillOfflineExplore(username) {let d = document.getElementById('ch-discover');if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; }anchor.insertAdjacentElement('afterend', d);-- if (typeof renderOfflineDiscover === 'function') renderOfflineDiscover(username, d);++ if (typeof renderOfflineDiscover === 'function') return renderOfflineDiscover(username, d);};if (!built.length) {host.innerHTML = '';@@@ -3046,10 +3063,9 @@ async function _fillOfflineExplore(username) {const re = document.getElementById('ch-offline-reopen');if (ov) ov.style.display = 'none';if (re) re.classList.remove('show');-- mountDiscover();-- return;++ return mountDiscover();}-- mountDiscover();++ const discovered = mountDiscover();_offlineRanges = built;_offlineIdx = 0;@@@ -3074,6 +3090,7 @@ async function _fillOfflineExplore(username) {</div>`;_offlineCyclerRender();_startOfflineCycler();++ return discovered;}function _startOfflineCycler() {@@@ -260,8 +260,27 @@ function vodFallBackToFile(video) {return true;}++// A direct visit paints a VOD's or clip's markup before its data. Until the data (mp-data) media.css outlines the info++// card's placeholder words; until the parts that load on their own are in (mp-ready: the AI timeline, the activity++// strip, the source card) it keeps them, the clips and the comments out of the layout, so they arrive together and++// push nothing on screen down. Never longer than 2.5 s after the data.++const _mediaReadyTimers = {};++function _mediaReady(pageId, ready = true) {++ const page = document.getElementById(pageId);++ if (page) { page.classList.toggle('mp-ready', ready); if (!ready) page.classList.remove('mp-data'); }++ clearTimeout(_mediaReadyTimers[pageId]);++}++function _mediaHasData(pageId) {++ const page = document.getElementById(pageId);++ if (page) page.classList.add('mp-data');++ clearTimeout(_mediaReadyTimers[pageId]);++ _mediaReadyTimers[pageId] = setTimeout(() => _mediaReady(pageId), 2500);++}++async function loadVodPlayer(vodId, seekTo) {const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;++ const blocks = [];++ _mediaReady('page-vod-player', false);try {// Clean up any previous live VOD pollif (window._liveVodPollTimer) {@@@ -287,13 +306,14 @@ async function loadVodPlayer(vodId, seekTo) {const data = await api(`/vods/${vodId}`);const v = data.vod;const clips = data.clips || [];++ _mediaHasData('page-vod-player');// Store for commentswindow._vpVodId = v.id;document.getElementById('vp-title').textContent = v.title || 'Video';setPageTitle(v.title || 'Video');-- loadVodAiTimeline(v.id);++ blocks.push(loadVodAiTimeline(v.id));document.getElementById('vp-streamer').textContent = v.display_name || v.username || 'Unknown';{ const _a = document.getElementById('vp-avatar'); if (_a) _a.innerHTML = _avatarInner(v.avatar_url, v.username); }document.getElementById('vp-date').textContent = formatDateTime(v.created_at);@@@ -353,7 +373,7 @@ async function loadVodPlayer(vodId, seekTo) {vpStream.style.display = 'none';}// Activity strip: chat lines, chatters, viewers, mic moments, follows, report.-- if (typeof renderVodContextStrip === 'function') renderVodContextStrip(v.id);++ if (typeof renderVodContextStrip === 'function') blocks.push(renderVodContextStrip(v.id));}const fileUrl = `/api/vods/${Number(v.id)}/file`;@@@ -530,7 +550,9 @@ async function loadVodPlayer(vodId, seekTo) {// Load commentsloadComments('vod', v.id, 'vp');++ Promise.allSettled(blocks).then(() => _mediaReady('page-vod-player'));} catch (e) {++ _mediaReady('page-vod-player');console.error('Failed to load VOD player', e);toast('Failed to load video: ' + (e.message || 'not found'), 'error');navigate('/vods');@@@ -1008,6 +1030,8 @@ function _renderClipAttribution(el, cl) {async function loadClipPlayer(clipId) {const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;++ const blocks = [];++ _mediaReady('page-clip-player', false);try {// Clean up chat replayif (window._chatReplayTimer) {@@@ -1022,6 +1046,7 @@ async function loadClipPlayer(clipId) {const data = await api(`/clips/${clipId}`);const cl = data.clip;++ _mediaHasData('page-clip-player');// Store for commentswindow._clpClipId = cl.id;@@@ -1095,7 +1120,7 @@ async function loadClipPlayer(clipId) {clpSource.style.display = 'none';}// Upgrade the plain line into the rich "from this stream" card when the VOD context loads.-- if (cl.vod_id && typeof renderClipSourceCard === 'function') renderClipSourceCard(cl);++ if (cl.vod_id && typeof renderClipSourceCard === 'function') blocks.push(renderClipSourceCard(cl));}const video = document.getElementById('clp-video');@@@ -1214,7 +1239,9 @@ async function loadClipPlayer(clipId) {// Load commentsloadComments('clip', cl.id, 'clp');++ Promise.allSettled(blocks).then(() => _mediaReady('page-clip-player'));} catch (e) {++ _mediaReady('page-clip-player');toast('Clip not found', 'error');navigate('/clips');}@@@ -5,7 +5,9 @@const API = ''; // same-originlet currentUser = null;--let currentPage = 'home';++// The page the server already shows (server/web/assets.js marks the route's own section active), so the first++// showPage() of a direct visit is not a "change" and does not hide and fade in a page that is already painted.++let currentPage = ((document.querySelector('section.page.active') || {}).id || 'page-home').replace(/^page-/, '');let currentStreamId = null;let currentStreamData = null; // increments each stream switch; guards against fast-switch raceslet openvibeAppMetaData = null;@@@ -276,7 +276,23 @@ function renderRoute(html, urlPath) {out = out.slice(0, start) + body + out.slice(end + '<!--/ov:fragment-->'.length);out = out.replace(`data-fragment="${frag}">`, `data-fragment="${frag}" data-fragment-loaded="1">`);}-- return out;++ return activePage(out, names, reg);++}++++/**++ * The route's own page is the one shown from the first paint. index.html marks the home page `active`, so a direct++ * visit to a channel, a VOD or the chat painted the home page (its hero and lists) until the router ran and swapped++ * pages — a flash of the wrong page and the largest layout shifts measured on the site (CLS 1.0 on a channel page,++ * 0.5 on a VOD, phone profile). The route's last feature with a page fragment names its section; the router's own++ * showPage() then finds it already active.++ */++function activePage(html, names, reg) {++ const frag = [...names].reverse().map((n) => reg.features[n] && reg.features[n].fragment)++ .find((f) => f && html.includes(`class="page" data-fragment="${f}"`));++ if (!frag) return html;++ return html++ .replace('<section id="page-home" class="page active">', '<section id="page-home" class="page">')++ .replace(`class="page" data-fragment="${frag}"`, `class="page active" data-fragment="${frag}"`);}/**nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++// A direct visit paints the right page and nothing on it jumps when the data lands (CLS): the server marks the route's++// own section active (server/web/assets.js activePage) and the router takes it as the current page; the channel, VOD++// and clip pages hold back the parts that arrive on their own until they are all in; the player keeps a 16:9 box; the++// home hero reserves its countdown line, its "shipped" line and its stats board; and the widgets built before their++// stylesheets stay out of the page.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const assets = require('../server/web/assets');++++const ROOT = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');++let pass = 0;++const ok = (m) => { pass++; console.log(` ✓ ${m}`); };++++// ── The route's own page is active from the first paint ──++const doc = assets.document('index.html').html;++const active = (html) => [...html.matchAll(/<section id="(page-[a-z-]+)" class="page active"/g)].map((m) => m[1]);++assert.deepStrictEqual(active(doc), ['page-home'], 'the shell itself shows home');++for (const [url, page] of [++ ['/', 'page-home'], ['/@someone', 'page-channel'], ['/vod/12', 'page-vod-player'], ['/clip/7', 'page-clip-player'],++ ['/chat', 'page-chat'], ['/content', 'page-content'], ['/dashboard', 'page-dashboard'],++]) {++ assert.deepStrictEqual(active(assets.renderRoute(doc, url)), [page], `${url}: only ${page} is active`);++}++// A route without a section of its own keeps home (the router shows its page as before).++assert.deepStrictEqual(active(assets.renderRoute(doc, '/settings')), ['page-home'], '/settings: unchanged');++const app = read('public/js/app.js');++assert.match(app, /let currentPage = \(\(document\.querySelector\('section\.page\.active'\) \|\| \{\}\)\.id \|\| 'page-home'\)\.replace\(\/\^page-\/, ''\);/,++ 'the router starts from the page the server shows, so its first showPage() is not a change');++ok('a direct visit shows the route\'s own section, and the router starts from it');++++// ── Channel: placeholder outlines, tabs held back, page height kept ──++const chJs = read('public/js/app-channel.js');++const chCss = read('public/css/features/channel.css');++assert.ok(/function _channelReady\(ready = true\)/.test(chJs) && chJs.includes("page.classList.add('ch-data')") && chJs.includes('setTimeout(_channelReady, 2500)'), 'the channel marks its data and readiness, with a cap');++assert.ok(chJs.includes('offlineBlocks = _renderOfflineScreen(ch);') && chJs.includes('Promise.resolve(offlineBlocks).catch(() => {}).then(() => _channelReady());'), 'ready waits for the offline blocks above the tabs');++assert.ok(chJs.includes('return _fillOfflineExplore(ch && ch.username);') && chJs.includes('return renderOfflineDiscover(username, d);') && chJs.includes('return discovered;'), 'the offline screen resolves after "Most watched" and the discover board');++assert.ok(chCss.includes('#page-channel:not(.ch-ready) .channel-below-fold { display: none; }'), 'tabs out of the layout until ready');++assert.ok(chCss.includes('#page-channel.page:not(.ch-ready) { min-height: 100vh; }'), 'the page keeps a full viewport until ready (beats "#page-channel.page { min-height: 0 }")');++assert.ok(chCss.indexOf('#page-channel.page:not(.ch-ready)') > chCss.indexOf('#page-channel.page { min-height: 0; }'), '…after the rule it overrides');++assert.ok(/#page-channel:not\(\.ch-data\) :is\(#ch-display-name-offline/.test(chCss), 'placeholder words are outlines until the data');++ok('the channel holds its tabs and height until its data and offline blocks are in');++++// ── VOD and clip pages ──++const mJs = read('public/js/app-media.js');++const mCss = read('public/css/features/media.css');++assert.ok(/function _mediaReady\(pageId, ready = true\)/.test(mJs) && /function _mediaHasData\(pageId\)/.test(mJs), 'one helper for both pages');++for (const [id, block] of [['page-vod-player', 'blocks.push(loadVodAiTimeline(v.id));'], ['page-clip-player', 'blocks.push(renderClipSourceCard(cl));']]) {++ assert.ok(mJs.includes(`_mediaReady('${id}', false);`) && mJs.includes(`_mediaHasData('${id}');`) && mJs.includes(`Promise.allSettled(blocks).then(() => _mediaReady('${id}'));`), `${id}: pending → data → ready`);++ assert.ok(mJs.includes(block), `${id}: waits for its own blocks`);++}++assert.ok(mJs.includes('blocks.push(renderVodContextStrip(v.id));'), 'the VOD also waits for the activity strip');++assert.ok(mCss.includes('#page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }'), 'VOD parts held back');++assert.ok(mCss.includes('#page-clip-player:not(.mp-ready) #clp-comments { display: none; }'), 'clip comments held back');++const style = read('public/css/style.css');++const player = /\.vod-player-container \{([^}]*)\}/.exec(style);++assert.ok(player && /aspect-ratio: 16 \/ 9;/.test(player[1]) && /max-height: 70vh;/.test(player[1]), 'the player keeps a 16:9 box (capped like the video) before the video knows its size');++ok('VOD and clip pages keep the player box and hold back the late parts');++++// ── Home hero and early widgets ──++const index = read('public/index.html');++assert.ok(/<div class="hero-slogan-timer" id="hero-slogan-timer"> <\/div>/.test(index), 'the countdown line is held from the first paint');++assert.ok(/<a class="hero-latest" id="hero-latest"[^>]*><\/a>/.test(index) && !/id="hero-latest"[^>]*\shidden>/.test(index), 'the shipped line is not hidden (shipped.js would collapse it, then refill it)');++const home = read('public/css/features/home.css');++assert.ok(home.includes('.hero-latest:empty { visibility: hidden; min-height: 33px; }'), 'the empty shipped line keeps its height, invisibly');++assert.ok(/\.hero-stats \{ margin: 16px 0 10px; gap: 10px; min-height: 120px; \}/.test(home) && home.includes('.hero-stats { min-height: 204px; }') && home.includes('.hero-stats { min-height: 256px; margin-top: 14px; }'),++ 'the stats board reserves its loaded height, "Show all" button included');++const inline = /<style id="ovsk-css">([\s\S]*?)<\/style>/.exec(index)[1];++assert.ok(inline.includes('.cosmetics-modal-overlay:not(.active),.messenger-panel:not(.open){display:none}') && /\.messenger-toggle\{position:fixed;/.test(inline), 'closed widgets stay out of the page until their stylesheets land');++ok('the home hero reserves its late lines and the early widgets stay out of the flow');++++console.log(`first-paint-stability: ${pass} checks passed`);
@@ -152,6 +152,32 @@ used 436 of its selectors, 17–18 % of the file.
@@ -152,6 +152,32 @@ used 436 of its selectors, 17–18 % of the file.
with the full file, just after the first paint. `test/critical-css.test.js` fails when more than a tenth of the listed
with the full file, just after the first paint. `test/critical-css.test.js` fails when more than a tenth of the listed
selectors are no longer in `style.css`.
selectors are no longer in `style.css`.
+## Measurement 2026-10-12: layout shift on direct visits
+
+With the first paint earlier, what moved afterwards showed: on a phone a direct visit to a channel scored CLS 1.31, a
+clip 0.99, a VOD 0.43 and the home page 0.13 (Chrome's "good" line is 0.1). Each had the same shape: the shell painted
+placeholder markup, then the data, and then several parts that load on their own, each pushing the page down.
+
+- **The right page from the start.** The shell marked home active, so a channel visit painted the home page until the
+ router swapped. `renderRoute` now moves `active` to the route's own section (`activePage` in `server/web/assets.js`),
+ and `app.js` starts `currentPage` from it, so its first `showPage()` is not a change and does not fade the page in again.
+- **Channel** (`_channelReady` in `app-channel.js`): until the data (`ch-data`) the header's placeholder words
+ ("Channel", "0 followers") are outlines; until the first render and, offline, the "Most watched" block and the discover
+ board (`ch-ready`, at most 2.5 s after the data) the tabs are out of the layout and the page keeps a full viewport, so
+ the footer never shows and climbs back down.
+- **VOD and clip** (`_mediaReady` in `app-media.js`, `mp-data`/`mp-ready`): the same outlines; the AI timeline, the
+ activity strip, the source card, the clips and the comments appear together. `.vod-player-container` keeps a 16:9 box
+ (`max-height: 70vh`, like the video) before the video knows its size; a taller video still grows it.
+- **Home hero:** the countdown line ships as ` ` instead of `hidden`; the "shipped" line is no longer `hidden`
+ (shipped.js collapsed it, then refilled it) and keeps its 33 px while empty; the stats board's reserved heights now
+ include the "Show all" button (256 / 204 / 120 px, phone / tablet / wide).
+- The cosmetics modal and the messenger panel are built by scripts whose stylesheets load without blocking; until those
+ land the shell's inline style keeps them closed and the messenger toggle in its corner.
+
+Same A/B harness (phone profile, worst of two runs): channel 1.31 → 0.012, clip 0.99 → 0.025, VOD 0.43 → 0.004, home
+0.127 → 0.011; at 1366 px channel 0.09 → 0.015, VOD 0.19 → 0.001, home 0.06 → 0.01. First paint unchanged.
+`test/first-paint-stability.test.js` holds the pieces in place.
+
## Budgets
## Budgets
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
@@ -1127,6 +1127,14 @@
@@ -1127,6 +1127,14 @@
.ch-actions { flex-shrink: 0; }
.ch-actions { flex-shrink: 0; }
.channel-below-fold { padding-top: 6px; }
.channel-below-fold { padding-top: 6px; }
+/* Before the channel's data (app-channel.js _channelReady): the page keeps a full viewport (below) so the footer stays
+ under the fold, the tabs stay out of the layout until what lands above them is in, and the header shows outlines
+ rather than its placeholder words ("Channel", "0 followers"). */
+#page-channel:not(.ch-ready) .channel-below-fold { display: none; }
+#page-channel:not(.ch-data) :is(#ch-display-name-offline, #ch-username-offline, #ch-follower-count-offline, #ch-category-badge-offline) {
+ width: fit-content; color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-color: transparent; border-radius: 6px;
+}
+#page-channel:not(.ch-data) #ch-avatar-offline { color: transparent; }
.ch-analytics-period-btns { display:flex; gap:4px; margin-left:auto; }
.ch-analytics-period-btns { display:flex; gap:4px; margin-left:auto; }
.analytics-stat-cards {
.analytics-stat-cards {
display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr));
@@ -1541,6 +1549,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -1541,6 +1549,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* The channel page ends where its content ends: a full-viewport minimum left a screen of empty space above the footer
/* The channel page ends where its content ends: a full-viewport minimum left a screen of empty space above the footer
whenever the open tab was short. */
whenever the open tab was short. */
#page-channel.page { min-height: 0; }
#page-channel.page { min-height: 0; }
+/* …except before its data, while it is the placeholder markup (see _channelReady). */
+#page-channel.page:not(.ch-ready) { min-height: 100vh; }
/* The offline area: one gutter for everything in its main column (the discover board sat flush with the edge). */
/* The offline area: one gutter for everything in its main column (the discover board sat flush with the edge). */
.ch-offline-area .stream-main > #ch-discover { padding: 0 14px; box-sizing: border-box; }
.ch-offline-area .stream-main > #ch-discover { padding: 0 14px; box-sizing: border-box; }
@@ -226,6 +226,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -226,6 +226,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
max-width: min(640px, 90vw); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: min(640px, 90vw); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
}
.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }
.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }
+/* Until shipped.js fills it, the line keeps its height without showing an empty pill, so the stats below stay put. */
+.hero-latest:empty { visibility: hidden; min-height: 33px; }
.pulse-card {
.pulse-card {
display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);
display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);
background: var(--bg-secondary); border: 1px solid var(--border);
background: var(--bg-secondary); border: 1px solid var(--border);
@@ -1005,9 +1007,10 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1005,9 +1007,10 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
label under it and the delta pill on a third line: ~80px tall cards that were mostly air.
label under it and the delta pill on a third line: ~80px tall cards that were mostly air.
Now: one 9-across row on wide screens (icon, number, label, short delta), three horizontal
Now: one 9-across row on wide screens (icon, number, label, short delta), three horizontal
rows on tablets (icon · number+label · delta right), three tight columns on phones. The
rows on tablets (icon · number+label · delta right), three tight columns on phones. The
- reserved min-height matches each layout so nothing jumps while the numbers load. */
+ reserved min-height matches each layout, "Show all" button included (measured 2026-10-12 at
+ 360–1920 px), so nothing jumps while the numbers load. */
.hero-latest { margin-top: 18px; }
.hero-latest { margin-top: 18px; }
-.hero-stats { margin: 16px 0 10px; gap: 10px; min-height: 108px; }
+.hero-stats { margin: 16px 0 10px; gap: 10px; min-height: 120px; }
.hero-stat-strip {
.hero-stat-strip {
display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); grid-auto-rows: auto;
display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); grid-auto-rows: auto;
gap: 8px; max-width: 1240px; margin: 0 auto; width: 100%; padding: 0;
gap: 8px; max-width: 1240px; margin: 0 auto; width: 100%; padding: 0;
@@ -1040,7 +1043,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1040,7 +1043,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-strip .hero-stat-chart-hint { font-size: 0.5rem; margin-left: 2px; }
.hero-stat-strip .hero-stat-chart-hint { font-size: 0.5rem; margin-left: 2px; }
/* Tablets and small laptops: three horizontal rows — icon · number + label · delta on the right. */
/* Tablets and small laptops: three horizontal rows — icon · number + label · delta on the right. */
@media (max-width: 1279px) {
@media (max-width: 1279px) {
- .hero-stats { min-height: 176px; }
+ .hero-stats { min-height: 204px; }
.hero-stat-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; max-width: 960px; }
.hero-stat-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; max-width: 960px; }
.hero-stat-strip .hero-stat { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: auto; padding: 8px 12px; }
.hero-stat-strip .hero-stat { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-rows: auto; padding: 8px 12px; }
.hero-stat-strip .hero-stat > i { grid-row: 1; }
.hero-stat-strip .hero-stat > i { grid-row: 1; }
@@ -1052,7 +1055,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1052,7 +1055,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
}
/* Phones: three tight columns, number over label, short pill. */
/* Phones: three tight columns, number over label, short pill. */
@media (max-width: 699px) {
@media (max-width: 699px) {
- .hero-stats { min-height: 214px; margin-top: 14px; }
+ .hero-stats { min-height: 256px; margin-top: 14px; }
.hero-stat-strip { gap: 6px; }
.hero-stat-strip { gap: 6px; }
.hero-stat-strip .hero-stat { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; padding: 7px 8px; column-gap: 6px; row-gap: 2px; border-radius: 10px; }
.hero-stat-strip .hero-stat { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; padding: 7px 8px; column-gap: 6px; row-gap: 2px; border-radius: 10px; }
.hero-stat-strip .hero-stat > i { grid-row: 1 / 3; width: 24px; height: 24px; font-size: 0.66rem; border-radius: 7px; }
.hero-stat-strip .hero-stat > i { grid-row: 1 / 3; width: 24px; height: 24px; font-size: 0.66rem; border-radius: 7px; }
@@ -108,6 +108,15 @@
@@ -108,6 +108,15 @@
border-radius: 0; display: block;
border-radius: 0; display: block;
}
}
.vod-player-info { margin-bottom: 24px; }
.vod-player-info { margin-bottom: 24px; }
+/* Before a VOD's or clip's data (app-media.js _mediaReady): outlines for the placeholder words, and the parts that
+ arrive on their own (AI timeline, activity strip, clips, comments) stay out of the layout until they are all in. */
+:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #vp-streamer, #vp-duration, #vp-views, #clp-title, #clp-streamer, #clp-duration, #clp-views) {
+ color: transparent; background: color-mix(in srgb, var(--text-muted) 14%, transparent); border-radius: 6px;
+}
+:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-title, #clp-title) { width: fit-content; }
+:is(#page-vod-player, #page-clip-player):not(.mp-data) :is(#vp-avatar, #clp-avatar, .vod-player-meta i) { visibility: hidden; }
+#page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }
+#page-clip-player:not(.mp-ready) #clp-comments { display: none; }
.vod-player-info {
.vod-player-info {
background: rgba(22,22,26,0.74);
background: rgba(22,22,26,0.74);
border: 1px solid rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.05);
@@ -2963,6 +2963,9 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2963,6 +2963,9 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
position: relative; width: 100%;
position: relative; width: 100%;
background: #000; border-radius: 0;
background: #000; border-radius: 0;
overflow: hidden;
overflow: hidden;
+ /* The usual shape before the video knows its own: a taller video still grows the box (aspect-ratio is a minimum
+ for content), and nothing below jumps when a 16:9 one arrives. */
+ aspect-ratio: 16 / 9; max-height: 70vh;
}
}
.vod-player-container:hover .video-overlay { opacity: 1; }
.vod-player-container:hover .video-overlay { opacity: 1; }
.vod-player-container.paused .video-overlay { opacity: 1; }
.vod-player-container.paused .video-overlay { opacity: 1; }
@@ -101,6 +101,10 @@
@@ -101,6 +101,10 @@
.ov-route-error>div{flex:1;min-width:180px;display:grid;gap:2px}
.ov-route-error>div{flex:1;min-width:180px;display:grid;gap:2px}
.ov-route-error span{color:var(--text-muted,#8b93ad);font-size:.9rem}
.ov-route-error span{color:var(--text-muted,#8b93ad);font-size:.9rem}
@media (prefers-reduced-motion:reduce){.ov-route-skel span{animation:none}}
@media (prefers-reduced-motion:reduce){.ov-route-skel span{animation:none}}
+ /* Widgets their scripts build before cosmetics.css / messenger.css land (both load without blocking): closed, they
+ stay out of the page instead of painting unstyled under the footer, and the toggle sits where it will. */
+ .cosmetics-modal-overlay:not(.active),.messenger-panel:not(.open){display:none}
+ .messenger-toggle{position:fixed;bottom:20px;left:20px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent-strong,#3472d8);color:var(--on-accent-strong,#fff)}
</style>
</style>
<!-- Stylesheets.
<!-- Stylesheets.
@@ -190,7 +194,8 @@
@@ -190,7 +194,8 @@
every few seconds would be read out to screen-reader users over and over. -->
every few seconds would be read out to screen-reader users over and over. -->
<p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate visible" id="hero-rotate">stealth campers</span></span></p>
<p class="hero-tagline">Live streaming for <span class="hero-rotate-wrap"><span class="hero-rotate visible" id="hero-rotate">stealth campers</span></span></p>
<p class="hero-quip visible" id="hero-quip">No investors. No suits. Just vibes.</p>
<p class="hero-quip visible" id="hero-quip">No investors. No suits. Just vibes.</p>
- <div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
+ <!-- Holds its line from the first paint (the countdown fills it with the page's data), so the buttons below do not move. -->
+ <div class="hero-slogan-timer" id="hero-slogan-timer"> </div>
<div class="hero-cta" data-fx-viewport>
<div class="hero-cta" data-fx-viewport>
<button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
<button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
<a href="https://openvibe.tools" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://openvibe.tools" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
@@ -221,7 +226,7 @@
@@ -221,7 +226,7 @@
</div>
</div>
<!-- Newest shipped commit — built in the open, receipts included. Sits under the
<!-- Newest shipped commit — built in the open, receipts included. Sits under the
philosophy row so the proof is next to the promise. -->
philosophy row so the proof is next to the promise. -->
- <a class="hero-latest" id="hero-latest" data-ov-shipped="latest" data-service="live" href="/updates" onclick="return handleLinkClick(event, '/updates')" hidden></a>
+ <a class="hero-latest" id="hero-latest" data-ov-shipped="latest" data-service="live" href="/updates" onclick="return handleLinkClick(event, '/updates')"></a>
<div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
<div class="hero-stats" id="hero-stats" aria-label="Site stats"><div class="ovsk ovsk-panel" aria-hidden="true"><div class="ovsk-bar" style="height:14px;width:45%"></div><div class="ovsk-bar" style="height:10px;width:92%"></div><div class="ovsk-bar" style="height:10px;width:68%"></div></div></div>
</div>
</div>
</div>
</div>
@@ -602,13 +602,26 @@ function _watchTabsClip() {
@@ -602,13 +602,26 @@ function _watchTabsClip() {
upd();
upd();
}
}
+// A direct visit paints the channel's markup before its data. Until the data (ch-data) channel.css outlines the
+// header's placeholder words; until the first render, and offline until the "Most watched" block and the discover
+// board that land above the tabs (ch-ready), it keeps the tabs out of the layout and the page at full height, so the
+// data arriving pushes nothing on screen down. Never longer than 2.5 s after the data.
+let _channelReadyTimer = null;
+function _channelReady(ready = true) {
+ const page = document.getElementById('page-channel');
+ if (page) { page.classList.toggle('ch-ready', ready); if (!ready) page.classList.remove('ch-data'); }
+ clearTimeout(_channelReadyTimer);
+}
+
async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {
async function loadChannelPage(username, managedStreamRef = null, legacySessionId = null) {
_restoreChannelChat();
_restoreChannelChat();
_watchTabsClip();
_watchTabsClip();
+ let offlineBlocks = null;
try {
try {
const isNewChannel = currentChannelUsername !== username;
const isNewChannel = currentChannelUsername !== username;
currentChannelUsername = username;
currentChannelUsername = username;
if (isNewChannel) {
if (isNewChannel) {
+ _channelReady(false);
channelVodsPageByUser[username] = 1;
channelVodsPageByUser[username] = 1;
channelClipsPageByUser[username] = 1;
channelClipsPageByUser[username] = 1;
// Reset VOD filter/sort state for fresh channel navigation
// Reset VOD filter/sort state for fresh channel navigation
@@ -632,6 +645,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -632,6 +645,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
const data = await api(`/streams/channel/${username}?vodLimit=${CHANNEL_VODS_PAGE_SIZE}&vodOffset=${channelVodOffset}&clipLimit=${CHANNEL_CLIPS_PAGE_SIZE}&clipOffset=${channelClipOffset}${initialVodExtra}`);
const data = await api(`/streams/channel/${username}?vodLimit=${CHANNEL_VODS_PAGE_SIZE}&vodOffset=${channelVodOffset}&clipLimit=${CHANNEL_CLIPS_PAGE_SIZE}&clipOffset=${channelClipOffset}${initialVodExtra}`);
const ch = data.channel;
const ch = data.channel;
+ { const page = document.getElementById('page-channel'); if (page) page.classList.add('ch-data'); if (page && !page.classList.contains('ch-ready')) _channelReadyTimer = setTimeout(_channelReady, 2500); }
// The tab says whose channel this is, as the server-rendered <title> did (a live stream
// The tab says whose channel this is, as the server-rendered <title> did (a live stream
// replaces it with the stream's title in activateChannelStream).
// replaces it with the stream's title in activateChannelStream).
if (ch && ch.username) setPageTitle(`${ch.display_name || ch.username} (@${ch.username})`);
if (ch && ch.username) setPageTitle(`${ch.display_name || ch.username} (@${ch.username})`);
@@ -809,7 +823,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -809,7 +823,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
setupBanBtn(document.getElementById('ch-btn-ban-offline'));
setupBanBtn(document.getElementById('ch-btn-ban-offline'));
// Customizable offline screen (image / video / custom HTML)
// Customizable offline screen (image / video / custom HTML)
- _renderOfflineScreen(ch);
+ offlineBlocks = _renderOfflineScreen(ch);
// Offline: join the streamer's PERSISTENT chat room (not global) so
// Offline: join the streamer's PERSISTENT chat room (not global) so
// viewers can keep chatting + see history while the streamer is offline.
// viewers can keep chatting + see history while the streamer is offline.
@@ -850,8 +864,10 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -850,8 +864,10 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
});
});
// Analytics is loaded lazily when its tab is first opened (see switchChannelTab).
// Analytics is loaded lazily when its tab is first opened (see switchChannelTab).
+ Promise.resolve(offlineBlocks).catch(() => {}).then(() => _channelReady());
} catch (e) {
} catch (e) {
+ _channelReady();
console.error('Channel load error:', e);
console.error('Channel load error:', e);
toast('Channel not found', 'error');
toast('Channel not found', 'error');
navigate('/');
navigate('/');
@@ -2919,6 +2935,7 @@ function _wireAboutDrag() {
@@ -2919,6 +2935,7 @@ function _wireAboutDrag() {
// Render a streamer's customizable offline screen into #ch-offline-screen.
// Render a streamer's customizable offline screen into #ch-offline-screen.
// image/video → served asset; html → sandboxed iframe (no same-origin, so the
// image/video → served asset; html → sandboxed iframe (no same-origin, so the
// streamer's markup can't touch viewers' session). Falls back to a tasteful default.
// streamer's markup can't touch viewers' session). Falls back to a tasteful default.
+// Resolves once the offline blocks that land above the tabs ("Most watched", the discover board) are in.
function _renderOfflineScreen(ch) {
function _renderOfflineScreen(ch) {
const host = document.getElementById('ch-offline-screen');
const host = document.getElementById('ch-offline-screen');
if (!host) return;
if (!host) return;
@@ -2935,7 +2952,7 @@ function _renderOfflineScreen(ch) {
@@ -2935,7 +2952,7 @@ function _renderOfflineScreen(ch) {
<div class="ch-offline-explore ch-offline-explore--over" id="ch-offline-explore"></div>
<div class="ch-offline-explore ch-offline-explore--over" id="ch-offline-explore"></div>
</div>
</div>
<button class="ch-offline-reopen" id="ch-offline-reopen" onclick="_reopenOfflineOverlay()" title="Show most-watched content"><i class="fa-solid fa-fire"></i> Top content</button>`;
<button class="ch-offline-reopen" id="ch-offline-reopen" onclick="_reopenOfflineOverlay()" title="Show most-watched content"><i class="fa-solid fa-fire"></i> Top content</button>`;
- _fillOfflineExplore(ch && ch.username);
+ return _fillOfflineExplore(ch && ch.username);
} else if (type === 'video' && url) {
} else if (type === 'video' && url) {
host.innerHTML = `<video class="ch-offline-media" src="${esc(url)}" autoplay muted loop playsinline></video>`;
host.innerHTML = `<video class="ch-offline-media" src="${esc(url)}" autoplay muted loop playsinline></video>`;
} else if (type === 'html' && (ch.offline_html || ch.offline_css)) {
} else if (type === 'html' && (ch.offline_html || ch.offline_css)) {
@@ -2962,7 +2979,7 @@ function _renderOfflineScreen(ch) {
@@ -2962,7 +2979,7 @@ function _renderOfflineScreen(ch) {
<div class="ch-offline-default-sub">is offline — explore their top content, or say hi in chat</div>
<div class="ch-offline-default-sub">is offline — explore their top content, or say hi in chat</div>
<div class="ch-offline-explore" id="ch-offline-explore"></div>
<div class="ch-offline-explore" id="ch-offline-explore"></div>
</div>`;
</div>`;
- _fillOfflineExplore(ch && ch.username);
+ return _fillOfflineExplore(ch && ch.username);
}
}
}
}
@@ -3037,7 +3054,7 @@ async function _fillOfflineExplore(username) {
@@ -3037,7 +3054,7 @@ async function _fillOfflineExplore(username) {
let d = document.getElementById('ch-discover');
let d = document.getElementById('ch-discover');
if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; }
if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; }
anchor.insertAdjacentElement('afterend', d);
anchor.insertAdjacentElement('afterend', d);
- if (typeof renderOfflineDiscover === 'function') renderOfflineDiscover(username, d);
+ if (typeof renderOfflineDiscover === 'function') return renderOfflineDiscover(username, d);
};
};
if (!built.length) {
if (!built.length) {
host.innerHTML = '';
host.innerHTML = '';
@@ -3046,10 +3063,9 @@ async function _fillOfflineExplore(username) {
@@ -3046,10 +3063,9 @@ async function _fillOfflineExplore(username) {
const re = document.getElementById('ch-offline-reopen');
const re = document.getElementById('ch-offline-reopen');
if (ov) ov.style.display = 'none';
if (ov) ov.style.display = 'none';
if (re) re.classList.remove('show');
if (re) re.classList.remove('show');
- mountDiscover();
- return;
+ return mountDiscover();
}
}
- mountDiscover();
+ const discovered = mountDiscover();
_offlineRanges = built;
_offlineRanges = built;
_offlineIdx = 0;
_offlineIdx = 0;
@@ -3074,6 +3090,7 @@ async function _fillOfflineExplore(username) {
@@ -3074,6 +3090,7 @@ async function _fillOfflineExplore(username) {
</div>`;
</div>`;
_offlineCyclerRender();
_offlineCyclerRender();
_startOfflineCycler();
_startOfflineCycler();
+ return discovered;
}
}
function _startOfflineCycler() {
function _startOfflineCycler() {
@@ -260,8 +260,27 @@ function vodFallBackToFile(video) {
@@ -260,8 +260,27 @@ function vodFallBackToFile(video) {
return true;
return true;
}
}
+// A direct visit paints a VOD's or clip's markup before its data. Until the data (mp-data) media.css outlines the info
+// card's placeholder words; until the parts that load on their own are in (mp-ready: the AI timeline, the activity
+// strip, the source card) it keeps them, the clips and the comments out of the layout, so they arrive together and
+// push nothing on screen down. Never longer than 2.5 s after the data.
+const _mediaReadyTimers = {};
+function _mediaReady(pageId, ready = true) {
+ const page = document.getElementById(pageId);
+ if (page) { page.classList.toggle('mp-ready', ready); if (!ready) page.classList.remove('mp-data'); }
+ clearTimeout(_mediaReadyTimers[pageId]);
+}
+function _mediaHasData(pageId) {
+ const page = document.getElementById(pageId);
+ if (page) page.classList.add('mp-data');
+ clearTimeout(_mediaReadyTimers[pageId]);
+ _mediaReadyTimers[pageId] = setTimeout(() => _mediaReady(pageId), 2500);
+}
+
async function loadVodPlayer(vodId, seekTo) {
async function loadVodPlayer(vodId, seekTo) {
const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
+ const blocks = [];
+ _mediaReady('page-vod-player', false);
try {
try {
// Clean up any previous live VOD poll
// Clean up any previous live VOD poll
if (window._liveVodPollTimer) {
if (window._liveVodPollTimer) {
@@ -287,13 +306,14 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -287,13 +306,14 @@ async function loadVodPlayer(vodId, seekTo) {
const data = await api(`/vods/${vodId}`);
const data = await api(`/vods/${vodId}`);
const v = data.vod;
const v = data.vod;
const clips = data.clips || [];
const clips = data.clips || [];
+ _mediaHasData('page-vod-player');
// Store for comments
// Store for comments
window._vpVodId = v.id;
window._vpVodId = v.id;
document.getElementById('vp-title').textContent = v.title || 'Video';
document.getElementById('vp-title').textContent = v.title || 'Video';
setPageTitle(v.title || 'Video');
setPageTitle(v.title || 'Video');
- loadVodAiTimeline(v.id);
+ blocks.push(loadVodAiTimeline(v.id));
document.getElementById('vp-streamer').textContent = v.display_name || v.username || 'Unknown';
document.getElementById('vp-streamer').textContent = v.display_name || v.username || 'Unknown';
{ const _a = document.getElementById('vp-avatar'); if (_a) _a.innerHTML = _avatarInner(v.avatar_url, v.username); }
{ const _a = document.getElementById('vp-avatar'); if (_a) _a.innerHTML = _avatarInner(v.avatar_url, v.username); }
document.getElementById('vp-date').textContent = formatDateTime(v.created_at);
document.getElementById('vp-date').textContent = formatDateTime(v.created_at);
@@ -353,7 +373,7 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -353,7 +373,7 @@ async function loadVodPlayer(vodId, seekTo) {
vpStream.style.display = 'none';
vpStream.style.display = 'none';
}
}
// Activity strip: chat lines, chatters, viewers, mic moments, follows, report.
// Activity strip: chat lines, chatters, viewers, mic moments, follows, report.
- if (typeof renderVodContextStrip === 'function') renderVodContextStrip(v.id);
+ if (typeof renderVodContextStrip === 'function') blocks.push(renderVodContextStrip(v.id));
}
}
const fileUrl = `/api/vods/${Number(v.id)}/file`;
const fileUrl = `/api/vods/${Number(v.id)}/file`;
@@ -530,7 +550,9 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -530,7 +550,9 @@ async function loadVodPlayer(vodId, seekTo) {
// Load comments
// Load comments
loadComments('vod', v.id, 'vp');
loadComments('vod', v.id, 'vp');
+ Promise.allSettled(blocks).then(() => _mediaReady('page-vod-player'));
} catch (e) {
} catch (e) {
+ _mediaReady('page-vod-player');
console.error('Failed to load VOD player', e);
console.error('Failed to load VOD player', e);
toast('Failed to load video: ' + (e.message || 'not found'), 'error');
toast('Failed to load video: ' + (e.message || 'not found'), 'error');
navigate('/vods');
navigate('/vods');
@@ -1008,6 +1030,8 @@ function _renderClipAttribution(el, cl) {
@@ -1008,6 +1030,8 @@ function _renderClipAttribution(el, cl) {
async function loadClipPlayer(clipId) {
async function loadClipPlayer(clipId) {
const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
+ const blocks = [];
+ _mediaReady('page-clip-player', false);
try {
try {
// Clean up chat replay
// Clean up chat replay
if (window._chatReplayTimer) {
if (window._chatReplayTimer) {
@@ -1022,6 +1046,7 @@ async function loadClipPlayer(clipId) {
@@ -1022,6 +1046,7 @@ async function loadClipPlayer(clipId) {
const data = await api(`/clips/${clipId}`);
const data = await api(`/clips/${clipId}`);
const cl = data.clip;
const cl = data.clip;
+ _mediaHasData('page-clip-player');
// Store for comments
// Store for comments
window._clpClipId = cl.id;
window._clpClipId = cl.id;
@@ -1095,7 +1120,7 @@ async function loadClipPlayer(clipId) {
@@ -1095,7 +1120,7 @@ async function loadClipPlayer(clipId) {
clpSource.style.display = 'none';
clpSource.style.display = 'none';
}
}
// Upgrade the plain line into the rich "from this stream" card when the VOD context loads.
// Upgrade the plain line into the rich "from this stream" card when the VOD context loads.
- if (cl.vod_id && typeof renderClipSourceCard === 'function') renderClipSourceCard(cl);
+ if (cl.vod_id && typeof renderClipSourceCard === 'function') blocks.push(renderClipSourceCard(cl));
}
}
const video = document.getElementById('clp-video');
const video = document.getElementById('clp-video');
@@ -1214,7 +1239,9 @@ async function loadClipPlayer(clipId) {
@@ -1214,7 +1239,9 @@ async function loadClipPlayer(clipId) {
// Load comments
// Load comments
loadComments('clip', cl.id, 'clp');
loadComments('clip', cl.id, 'clp');
+ Promise.allSettled(blocks).then(() => _mediaReady('page-clip-player'));
} catch (e) {
} catch (e) {
+ _mediaReady('page-clip-player');
toast('Clip not found', 'error');
toast('Clip not found', 'error');
navigate('/clips');
navigate('/clips');
}
}
@@ -5,7 +5,9 @@
@@ -5,7 +5,9 @@
const API = ''; // same-origin
const API = ''; // same-origin
let currentUser = null;
let currentUser = null;
-let currentPage = 'home';
+// The page the server already shows (server/web/assets.js marks the route's own section active), so the first
+// showPage() of a direct visit is not a "change" and does not hide and fade in a page that is already painted.
+let currentPage = ((document.querySelector('section.page.active') || {}).id || 'page-home').replace(/^page-/, '');
let currentStreamId = null;
let currentStreamId = null;
let currentStreamData = null; // increments each stream switch; guards against fast-switch races
let currentStreamData = null; // increments each stream switch; guards against fast-switch races
let openvibeAppMetaData = null;
let openvibeAppMetaData = null;
@@ -276,7 +276,23 @@ function renderRoute(html, urlPath) {
@@ -276,7 +276,23 @@ function renderRoute(html, urlPath) {
out = out.slice(0, start) + body + out.slice(end + '<!--/ov:fragment-->'.length);
out = out.slice(0, start) + body + out.slice(end + '<!--/ov:fragment-->'.length);
out = out.replace(`data-fragment="${frag}">`, `data-fragment="${frag}" data-fragment-loaded="1">`);
out = out.replace(`data-fragment="${frag}">`, `data-fragment="${frag}" data-fragment-loaded="1">`);
}
}
- return out;
+ return activePage(out, names, reg);
+}
+
+/**
+ * The route's own page is the one shown from the first paint. index.html marks the home page `active`, so a direct
+ * visit to a channel, a VOD or the chat painted the home page (its hero and lists) until the router ran and swapped
+ * pages — a flash of the wrong page and the largest layout shifts measured on the site (CLS 1.0 on a channel page,
+ * 0.5 on a VOD, phone profile). The route's last feature with a page fragment names its section; the router's own
+ * showPage() then finds it already active.
+ */
+function activePage(html, names, reg) {
+ const frag = [...names].reverse().map((n) => reg.features[n] && reg.features[n].fragment)
+ .find((f) => f && html.includes(`class="page" data-fragment="${f}"`));
+ if (!frag) return html;
+ return html
+ .replace('<section id="page-home" class="page active">', '<section id="page-home" class="page">')
+ .replace(`class="page" data-fragment="${frag}"`, `class="page active" data-fragment="${frag}"`);
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+// A direct visit paints the right page and nothing on it jumps when the data lands (CLS): the server marks the route's
+// own section active (server/web/assets.js activePage) and the router takes it as the current page; the channel, VOD
+// and clip pages hold back the parts that arrive on their own until they are all in; the player keeps a 16:9 box; the
+// home hero reserves its countdown line, its "shipped" line and its stats board; and the widgets built before their
+// stylesheets stay out of the page.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const assets = require('../server/web/assets');
+
+const ROOT = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
+let pass = 0;
+const ok = (m) => { pass++; console.log(` ✓ ${m}`); };
+
+// ── The route's own page is active from the first paint ──
+const doc = assets.document('index.html').html;
+const active = (html) => [...html.matchAll(/<section id="(page-[a-z-]+)" class="page active"/g)].map((m) => m[1]);
+assert.deepStrictEqual(active(doc), ['page-home'], 'the shell itself shows home');
+for (const [url, page] of [
+ ['/', 'page-home'], ['/@someone', 'page-channel'], ['/vod/12', 'page-vod-player'], ['/clip/7', 'page-clip-player'],
+ ['/chat', 'page-chat'], ['/content', 'page-content'], ['/dashboard', 'page-dashboard'],
+]) {
+ assert.deepStrictEqual(active(assets.renderRoute(doc, url)), [page], `${url}: only ${page} is active`);
+}
+// A route without a section of its own keeps home (the router shows its page as before).
+assert.deepStrictEqual(active(assets.renderRoute(doc, '/settings')), ['page-home'], '/settings: unchanged');
+const app = read('public/js/app.js');
+assert.match(app, /let currentPage = \(\(document\.querySelector\('section\.page\.active'\) \|\| \{\}\)\.id \|\| 'page-home'\)\.replace\(\/\^page-\/, ''\);/,
+ 'the router starts from the page the server shows, so its first showPage() is not a change');
+ok('a direct visit shows the route\'s own section, and the router starts from it');
+
+// ── Channel: placeholder outlines, tabs held back, page height kept ──
+const chJs = read('public/js/app-channel.js');
+const chCss = read('public/css/features/channel.css');
+assert.ok(/function _channelReady\(ready = true\)/.test(chJs) && chJs.includes("page.classList.add('ch-data')") && chJs.includes('setTimeout(_channelReady, 2500)'), 'the channel marks its data and readiness, with a cap');
+assert.ok(chJs.includes('offlineBlocks = _renderOfflineScreen(ch);') && chJs.includes('Promise.resolve(offlineBlocks).catch(() => {}).then(() => _channelReady());'), 'ready waits for the offline blocks above the tabs');
+assert.ok(chJs.includes('return _fillOfflineExplore(ch && ch.username);') && chJs.includes('return renderOfflineDiscover(username, d);') && chJs.includes('return discovered;'), 'the offline screen resolves after "Most watched" and the discover board');
+assert.ok(chCss.includes('#page-channel:not(.ch-ready) .channel-below-fold { display: none; }'), 'tabs out of the layout until ready');
+assert.ok(chCss.includes('#page-channel.page:not(.ch-ready) { min-height: 100vh; }'), 'the page keeps a full viewport until ready (beats "#page-channel.page { min-height: 0 }")');
+assert.ok(chCss.indexOf('#page-channel.page:not(.ch-ready)') > chCss.indexOf('#page-channel.page { min-height: 0; }'), '…after the rule it overrides');
+assert.ok(/#page-channel:not\(\.ch-data\) :is\(#ch-display-name-offline/.test(chCss), 'placeholder words are outlines until the data');
+ok('the channel holds its tabs and height until its data and offline blocks are in');
+
+// ── VOD and clip pages ──
+const mJs = read('public/js/app-media.js');
+const mCss = read('public/css/features/media.css');
+assert.ok(/function _mediaReady\(pageId, ready = true\)/.test(mJs) && /function _mediaHasData\(pageId\)/.test(mJs), 'one helper for both pages');
+for (const [id, block] of [['page-vod-player', 'blocks.push(loadVodAiTimeline(v.id));'], ['page-clip-player', 'blocks.push(renderClipSourceCard(cl));']]) {
+ assert.ok(mJs.includes(`_mediaReady('${id}', false);`) && mJs.includes(`_mediaHasData('${id}');`) && mJs.includes(`Promise.allSettled(blocks).then(() => _mediaReady('${id}'));`), `${id}: pending → data → ready`);
+ assert.ok(mJs.includes(block), `${id}: waits for its own blocks`);
+}
+assert.ok(mJs.includes('blocks.push(renderVodContextStrip(v.id));'), 'the VOD also waits for the activity strip');
+assert.ok(mCss.includes('#page-vod-player:not(.mp-ready) :is(#vp-ai-timeline, #vp-context, .vod-player-layout > .section-header, #vp-clips-grid, #vp-comments) { display: none; }'), 'VOD parts held back');
+assert.ok(mCss.includes('#page-clip-player:not(.mp-ready) #clp-comments { display: none; }'), 'clip comments held back');
+const style = read('public/css/style.css');
+const player = /\.vod-player-container \{([^}]*)\}/.exec(style);
+assert.ok(player && /aspect-ratio: 16 \/ 9;/.test(player[1]) && /max-height: 70vh;/.test(player[1]), 'the player keeps a 16:9 box (capped like the video) before the video knows its size');
+ok('VOD and clip pages keep the player box and hold back the late parts');
+
+// ── Home hero and early widgets ──
+const index = read('public/index.html');
+assert.ok(/<div class="hero-slogan-timer" id="hero-slogan-timer"> <\/div>/.test(index), 'the countdown line is held from the first paint');
+assert.ok(/<a class="hero-latest" id="hero-latest"[^>]*><\/a>/.test(index) && !/id="hero-latest"[^>]*\shidden>/.test(index), 'the shipped line is not hidden (shipped.js would collapse it, then refill it)');
+const home = read('public/css/features/home.css');
+assert.ok(home.includes('.hero-latest:empty { visibility: hidden; min-height: 33px; }'), 'the empty shipped line keeps its height, invisibly');
+assert.ok(/\.hero-stats \{ margin: 16px 0 10px; gap: 10px; min-height: 120px; \}/.test(home) && home.includes('.hero-stats { min-height: 204px; }') && home.includes('.hero-stats { min-height: 256px; margin-top: 14px; }'),
+ 'the stats board reserves its loaded height, "Show all" button included');
+const inline = /<style id="ovsk-css">([\s\S]*?)<\/style>/.exec(index)[1];
+assert.ok(inline.includes('.cosmetics-modal-overlay:not(.active),.messenger-panel:not(.open){display:none}') && /\.messenger-toggle\{position:fixed;/.test(inline), 'closed widgets stay out of the page until their stylesheets land');
+ok('the home hero reserves its late lines and the early widgets stay out of the flow');
+
+console.log(`first-paint-stability: ${pass} checks passed`);