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)

OP OpenVibers committed 12 Oct 2026 · 1564ce87bf · 11 files, +210 −18

After #113 moved the first paint earlier, what moves afterwards stood out. On a phone, a direct visit scored these CLS values (Chrome's "good" line is 0.1):

| Page | CLS |
|---|---|
| Channel | 1.31 |
| Clip | 0.99 |
| VOD | 0.43 |
| Home | 0.13 |

Every page followed the same pattern: the shell painted placeholder markup, then the data arrived, then several parts that load separately, and each one pushed the page down.

**What changes**
- **The right page from the start.** The shell marks home active, so a visit to a channel painted the home page until the router swapped pages.
  - `renderRoute` now moves `active` to the route's own section (`activePage` in `server/web/assets.js`).
  - `app.js` starts `currentPage` from that section, 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 arrives (`ch-data`), the header's placeholder words ("Channel", "username", "0 followers", "Offline") show as outlines.
  - Until the first render (`ch-ready`), the tabs stay out of the layout and the page keeps a full viewport, so the footer never shows and then drops away. For an offline channel, `ch-ready` also waits for the "Most watched" block and the discover board, which land above the tabs. It is set at most 2.5 s after the data.
- **VOD and clip** (`_mediaReady` in `app-media.js`, `mp-data` / `mp-ready`):
  - The same outlines on the info card.
  - The AI timeline, activity strip, source card, clips and comments appear together.
  - `.vod-player-container` keeps a 16:9 box before the video knows its size, capped at `max-height: 70vh` like the video. A taller video still grows it.
- **Home hero:**
  - The countdown line ships as ` ` instead of `hidden`.
  - The "shipped" line is no longer `hidden`. Before, shipped.js collapsed it and then refilled it; now it keeps its 33 px while empty.
  - The stats board's reserved heights now include the "Show all" button: 256 / 204 / 120 px for phone / tablet / wide, measured at 360–1920 px.
- **Early widgets:** scripts build the cosmetics modal and the messenger panel before their stylesheets (which load without blocking) arrive. Until then, they painted unstyled under the footer. The shell's inline style now keeps them closed and the messenger toggle in its corner.

**Measured.** Production pages A/B through a local same-origin proxy that serves this branch's files to B (brotli, worst of two runs):

| Page | Phone CLS | 1366 px CLS |
|---|---|---|
| Channel | 1.31 → 0.012 | 0.09 → 0.015 |
| Clip | 0.99 → 0.025 | — |
| VOD | 0.43 → 0.004 | 0.19 → 0.001 |
| Home | 0.127 → 0.011 | 0.06 → 0.01 |

First paint is unchanged (phone B ≤ A on every page). The settled pages look as before; I checked screenshots of the channel, VOD, clip and home pages at 3 s and at the end.

**Tests**
- `test/first-paint-stability.test.js` covers:
  - only the route's section is active for `/`, `/@someone`, `/vod/12`, `/clip/7`, `/chat`, `/content` and `/dashboard`, and `/settings` is unchanged;
  - the router's starting page;
  - the channel, VOD and clip pending → data → ready wiring and its CSS;
  - the player box;
  - the hero reservations;
  - the early-widget rules.
- `ov test` (41 files covering the change), `critical-css.test.js` and `check-budgets.js` pass.

Changes

ddiff --git a/docs/performance-audit.md b/docs/performance-audit.md
iindex 803c483a..0489c896 100644
---- a/docs/performance-audit.md
++++ b/docs/performance-audit.md
@@@ -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
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
`npm run perf:budget` (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 713c24fd..95b8cbb3 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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 footer
whenever 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; }
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 03a30707..758233aa 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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 horizontal
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-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; }
ddiff --git a/public/css/features/media.css b/public/css/features/media.css
iindex 528926f4..ba7b710a 100644
---- a/public/css/features/media.css
++++ b/public/css/features/media.css
@@@ -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);
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 9e953c90..fe5528ca 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; }
ddiff --git a/public/index.html b/public/index.html
iindex 60b108db..4da5a258 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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">&nbsp;</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 the
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>
</div>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex cbc6d71b..18cc7b57 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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, legacySessionI
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 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, legacySessionI
setupBanBtn(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() {
ddiff --git a/public/js/app-media.js b/public/js/app-media.js
iindex 7752dd21..0b46dcd2 100644
---- a/public/js/app-media.js
++++ b/public/js/app-media.js
@@@ -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 poll
if (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 comments
window._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 comments
loadComments('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 replay
if (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 comments
window._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 comments
loadComments('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');
}
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 269c9b32..f67499ab 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -5,7 +5,9 @@
const API = ''; // same-origin
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 currentStreamData = null; // increments each stream switch; guards against fast-switch races
let openvibeAppMetaData = null;
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 95e451fc..de47670a 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -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}"`);
}
/**
ddiff --git a/test/first-paint-stability.test.js b/test/first-paint-stability.test.js
nnew file mode 100644
iindex 00000000..0703067d
---- /dev/null
++++ b/test/first-paint-stability.test.js
@@@ -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">&nbsp;<\/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`);