openvibers/openvibe.live Commits
Channel page: every section on the page, not behind tabs (#678)
Changes
@@@ -1735,3 +1735,35 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:}#page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar :is(.emote-picker-btn, .chat-tts-toggle) { color: #fff; }}++++/* ── Sections, not tabs (owner 10-31: content is never hidden behind a tab to shorten a page) ──++ Every channel section is on the page in the order people look for them; the row above is a sticky jump bar with++ counts (it follows the section in view); long lists ride sideways on a phone; AI timeline and Analytics are counted++ folds. Scoped to the channel: the dashboard's settings tabs keep their tabs. */++#page-channel .channel-below-fold > .container { display: flex; flex-direction: column; }++#page-channel #ch-tabs { order: 0; position: sticky; top: var(--ovnav-h, 56px); z-index: 6; margin: 0 0 16px; background: var(--bg-primary); }++#page-channel .ch-tab-panel { display: block; order: 50; margin: 0 0 30px; animation: none; scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px); }++#page-channel .ch-tab-panel[hidden] { display: none; }++#page-channel #ch-panel-about { order: 1; } #page-channel #ch-panel-videos { order: 2; } #page-channel #ch-panel-clips { order: 3; }++#page-channel #ch-panel-agents { order: 4; } #page-channel #ch-panel-clips-taken { order: 5; } #page-channel #ch-panel-pastes { order: 6; }++#page-channel #ch-panel-media { order: 7; } #page-channel #ch-panel-ai-timeline { order: 8; } #page-channel #ch-panel-analytics { order: 9; }++#page-channel .ch-sec-h { margin: 0 0 10px; font-size: 1.12rem; font-weight: 800; letter-spacing: -.01em; }++#page-channel .ch-tab-panel > .ch-panel-hint:first-of-type { margin-top: -4px; }++#page-channel .ch-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 6px 14px; cursor: pointer; list-style: none;++ border: 1px solid var(--border); border-radius: 12px; background: color-mix(in srgb, var(--text-primary) 3%, transparent); }++#page-channel .ch-fold > summary::-webkit-details-marker { display: none; }++#page-channel .ch-fold > summary .ch-sec-h { margin: 0; }++#page-channel .ch-fold-n { color: var(--text-muted); font-size: .85rem; font-weight: 700; }++#page-channel .ch-fold-say { margin-left: auto; color: var(--accent); font-size: .85rem; font-weight: 700; }++#page-channel .ch-fold[open] > summary { margin-bottom: 12px; }++#page-channel .ch-fold[open] .ch-fold-say { font-size: 0; }++#page-channel .ch-fold[open] .ch-fold-say::after { content: "Hide"; font-size: .85rem; }++@media (hover: hover) { #page-channel .ch-fold > summary:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } }++@media (max-width: 760px) {++ /* Long lists ride sideways on a phone: every card is there, the page stays a few screens tall. */++ #page-channel .ch-tab-panel .stream-grid { display: grid; grid-auto-flow: column; grid-template-columns: none; grid-auto-columns: minmax(220px, 78%);++ gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; padding-bottom: 6px; scrollbar-width: thin; }++ #page-channel .ch-tab-panel .stream-grid > * { scroll-snap-align: start; min-width: 0; }++ #page-channel .ch-tab-panel .stream-grid > p.muted { grid-column: auto; }++}++@media (prefers-reduced-motion: reduce) { #page-channel .ch-tab-panel { scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px); } }@@@ -363,32 +363,35 @@</div><!-- /.stream-layout --></div>-- <!-- Channel content — tabbed (below stream) -->++ <!-- Channel content (below stream): every section on the page, never behind a tab (owner 10-31: content is never++ hidden to shorten a page). The row is a jump bar with counts; AI timeline and Analytics are counted folds. --><div class="channel-below-fold"><div class="container">-- <div class="ch-tabs" id="ch-tabs" role="tablist" aria-label="Channel sections">-- <button class="ch-tab" id="ch-tab-btn-about" role="tab" aria-selected="false" data-tab="about" onclick="switchChannelTab('about', this)" style="display:none"><i class="fa-solid fa-circle-info"></i> About<span class="ch-tab-edit" id="ch-tab-about-edit" role="button" tabindex="0" title="Edit About" onclick="event.stopPropagation(); editAboutFromTab();" style="display:none"><i class="fa-solid fa-pen"></i></span></button>-- <button class="ch-tab active" id="ch-tab-btn-videos" role="tab" aria-selected="true" data-tab="videos" onclick="switchChannelTab('videos', this)"><i class="fa-solid fa-video"></i> Videos<span class="ch-tab-badge" id="ch-tab-badge-videos" hidden></span></button>-- <button class="ch-tab" id="ch-tab-btn-agents" role="tab" aria-selected="false" data-tab="agents" onclick="switchChannelTab('agents', this)" style="display:none"><i class="fa-solid fa-robot"></i> Agents<span class="ch-tab-badge" id="ch-tab-badge-agents" hidden></span></button>-- <button class="ch-tab" id="ch-tab-btn-clips" role="tab" aria-selected="false" data-tab="clips" onclick="switchChannelTab('clips', this)"><i class="fa-solid fa-scissors"></i> Clips<span class="ch-tab-badge" id="ch-tab-badge-clips" hidden></span></button>-- <button class="ch-tab" id="ch-tab-btn-clips-taken" role="tab" aria-selected="false" data-tab="clips-taken" onclick="switchChannelTab('clips-taken', this)"><i class="fa-solid fa-film"></i> Clips Taken<span class="ch-tab-badge" id="ch-tab-badge-clips-taken" hidden></span></button>-- <button class="ch-tab" id="ch-tab-btn-pastes" role="tab" aria-selected="false" data-tab="pastes" onclick="switchChannelTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes<span class="ch-tab-badge" id="ch-tab-badge-pastes" hidden></span></button>-- <button class="ch-tab" id="ch-tab-btn-ai-timeline" role="tab" aria-selected="false" data-tab="ai-timeline" onclick="switchChannelTab('ai-timeline', this)"><i class="fa-solid fa-brain"></i> AI Timeline<span class="ch-tab-badge" id="ch-tab-badge-ai-timeline" hidden></span></button>-- <button class="ch-tab" id="ch-tab-btn-media" role="tab" aria-selected="false" data-tab="media" onclick="switchChannelTab('media', this)" style="display:none"><i class="fa-solid fa-music"></i> Media Request</button>-- <button class="ch-tab" role="tab" aria-selected="false" data-tab="analytics" onclick="switchChannelTab('analytics', this)"><i class="fa-solid fa-chart-line"></i> Analytics</button>-- </div>++ <nav class="ch-tabs ch-jump" id="ch-tabs" aria-label="Jump to a section">++ <button class="ch-tab" id="ch-tab-btn-about" data-tab="about" onclick=\"switchChannelTab('about', this, true)\" style="display:none"><i class="fa-solid fa-circle-info"></i> About<span class="ch-tab-edit" id="ch-tab-about-edit" role="button" tabindex="0" title="Edit About" onclick="event.stopPropagation(); editAboutFromTab();" style="display:none"><i class="fa-solid fa-pen"></i></span></button>++ <button class="ch-tab active" id="ch-tab-btn-videos" data-tab="videos" onclick=\"switchChannelTab('videos', this, true)\"><i class="fa-solid fa-video"></i> Videos<span class="ch-tab-badge" id="ch-tab-badge-videos" hidden></span></button>++ <button class="ch-tab" id="ch-tab-btn-agents" data-tab="agents" onclick=\"switchChannelTab('agents', this, true)\" style="display:none"><i class="fa-solid fa-robot"></i> Agents<span class="ch-tab-badge" id="ch-tab-badge-agents" hidden></span></button>++ <button class="ch-tab" id="ch-tab-btn-clips" data-tab="clips" onclick=\"switchChannelTab('clips', this, true)\"><i class="fa-solid fa-scissors"></i> Clips<span class="ch-tab-badge" id="ch-tab-badge-clips" hidden></span></button>++ <button class="ch-tab" id="ch-tab-btn-clips-taken" data-tab="clips-taken" onclick=\"switchChannelTab('clips-taken', this, true)\"><i class="fa-solid fa-film"></i> Clips Taken<span class="ch-tab-badge" id="ch-tab-badge-clips-taken" hidden></span></button>++ <button class="ch-tab" id="ch-tab-btn-pastes" data-tab="pastes" onclick=\"switchChannelTab('pastes', this, true)\"><i class="fa-solid fa-paste"></i> Pastes<span class="ch-tab-badge" id="ch-tab-badge-pastes" hidden></span></button>++ <button class="ch-tab" id="ch-tab-btn-ai-timeline" data-tab="ai-timeline" onclick=\"switchChannelTab('ai-timeline', this, true)\"><i class="fa-solid fa-brain"></i> AI Timeline<span class="ch-tab-badge" id="ch-tab-badge-ai-timeline" hidden></span></button>++ <button class="ch-tab" id="ch-tab-btn-media" data-tab="media" onclick=\"switchChannelTab('media', this, true)\" style="display:none"><i class="fa-solid fa-music"></i> Media Request</button>++ <button class="ch-tab" data-tab="analytics" onclick=\"switchChannelTab('analytics', this, true)\"><i class="fa-solid fa-chart-line"></i> Analytics</button>++ </nav><!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. -->-- <div class="ch-tab-panel" id="ch-panel-agents" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-agents" aria-labelledby="ch-h-agents">++ <h2 class="ch-sec-h" id="ch-h-agents">Agents</h2><p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p><h3 class="agent-schedules-title" id="ch-agents-schedules-title" hidden>Coming up</h3><div class="agent-schedules" id="ch-agents-schedules"></div><div class="agent-cards" id="ch-agents-grid"></div><button class="btn btn-small btn-outline agent-more" id="ch-agents-more" type="button" hidden onclick="loadMoreChannelAgents(this)">More</button>-- </div>++ </section><!-- Videos -->-- <div class="ch-tab-panel active" id="ch-panel-videos" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-videos" aria-labelledby="ch-h-videos">++ <h2 class="ch-sec-h" id="ch-h-videos">Videos</h2><div class="ch-panel-bar"><div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div><a id="ch-vods-video-link" class="btn btn-small btn-outline" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists"><i class="fa-solid fa-film"></i> On OpenVibe.Video</a>@@@ -396,10 +399,11 @@</div><div class="stream-grid" id="ch-vods-grid"><p class="muted">Loading videos…</p></div><div id="ch-vods-pagination" class="pastes-pagination" style="display:none"></div>-- </div>++ </section><!-- Clips (clips OF this streamer's streams) -->-- <div class="ch-tab-panel" id="ch-panel-clips" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-clips" aria-labelledby="ch-h-clips">++ <h2 class="ch-sec-h" id="ch-h-clips">Clips</h2><p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p><div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div><div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>@@@ -409,10 +413,11 @@<div class="stream-grid" id="ch-ai-clips-grid"></div><div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div></div>-- </div>++ </section><!-- Clips Taken (clips this streamer made of others) -->-- <div class="ch-tab-panel" id="ch-panel-clips-taken" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-clips-taken" aria-labelledby="ch-h-clips-taken">++ <h2 class="ch-sec-h" id="ch-h-clips-taken">Clips taken</h2><p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p><div class="ch-panel-bar"><div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>@@@ -420,24 +425,27 @@</div><div class="stream-grid" id="ch-clips-grid"><p class="muted">Loading clips…</p></div><div id="ch-clips-pagination" class="pastes-pagination" style="display:none"></div>-- </div>++ </section><!-- Pastes -->-- <div class="ch-tab-panel" id="ch-panel-pastes" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-pastes" aria-labelledby="ch-h-pastes">++ <h2 class="ch-sec-h" id="ch-h-pastes">Pastes</h2><div class="ch-panel-bar" id="ch-pastes-header"><button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button></div><div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div><div id="ch-pastes-pagination" class="pastes-pagination" style="display:none"></div>-- </div>++ </section><!-- Media Request -->-- <div class="ch-tab-panel" id="ch-panel-media" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-media" aria-labelledby="ch-h-media">++ <h2 class="ch-sec-h" id="ch-h-media">Media requests</h2><div id="ch-media-content"><div class="loading">Loading…</div></div>-- </div>++ </section><!-- Analytics -->-- <div class="ch-tab-panel" id="ch-panel-analytics" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-analytics" aria-labelledby="ch-h-analytics">++ <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics">Analytics</h2><span class="ch-fold-say">Show</span></summary><div class="ch-panel-bar" id="ch-analytics-header"><div class="ch-analytics-period-btns" id="ch-analytics-period-btns"><button class="btn btn-sm btn-ghost active" data-days="7">7d</button>@@@ -463,17 +471,21 @@</div></div></div>-- </div>++ </details>++ </section><!-- AI Timeline — the streamer's whole AI-observed history -->-- <div class="ch-tab-panel" id="ch-panel-ai-timeline" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-ai-timeline" aria-labelledby="ch-h-ai-timeline">++ <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline">AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary><div id="ch-ai-timeline"><div class="loading">Loading AI timeline…</div></div>-- </div>++ </details>++ </section><!-- About (weather is now an optional panel inside here) -->-- <div class="ch-tab-panel" id="ch-panel-about" role="tabpanel">++ <section class="ch-tab-panel" id="ch-panel-about" aria-labelledby="ch-h-about">++ <h2 class="ch-sec-h" id="ch-h-about">About</h2><div id="ch-about-content" class="ch-about-content"></div>-- </div>++ </section></div></div>@@@ -2030,16 +2030,34 @@ function loadMoreChannelAgents(button) {try { _agentsPaging.grid.more(); } finally { if (button) button.disabled = false; }}--function switchChannelTab(tab, btn) {++// The channel's sections are all on the page (owner 10-31: content is never hidden behind a tab). The row above them is a++// jump bar: a press scrolls to the section (and opens a fold); the section loads its data the first time it comes near++// the screen, or when it is jumped to. Inline handlers keep this name (features.json stubs).++function switchChannelTab(tab, btn, jump) {_channelTab = tab;++ _markChannelJump(tab);++ _ensureChannelSection(tab);++ if (!jump) return;++ const panel = document.getElementById('ch-panel-' + tab);++ if (!panel || panel.hidden) return;++ const fold = panel.querySelector('details.ch-fold');++ if (fold && !fold.open) fold.open = true;++ const reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);++ panel.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });++}++function _markChannelJump(tab) {document.querySelectorAll('#ch-tabs .ch-tab').forEach(b => {const on = b.dataset.tab === tab;b.classList.toggle('active', on);-- b.setAttribute('aria-selected', on ? 'true' : 'false');++ if (on) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');});-- document.querySelectorAll('.ch-tab-panel').forEach(p => p.classList.remove('active'));-- const panel = document.getElementById('ch-panel-' + tab);-- if (panel) panel.classList.add('active');++}++// A section's data: once, the first time it is near the screen or jumped to. The two folds (AI timeline, Analytics)++// load when opened, not on the way past; the media queue reloads when it comes back into view (it changes constantly).++let _chMediaAt = 0;++function _ensureChannelSection(tab, passing) {++ if (passing && (tab === 'analytics' || tab === 'ai-timeline')) return;++ if (tab === 'media') { if (Date.now() - _chMediaAt < 15000) return; _chMediaAt = Date.now(); }// Lazy-load the fetched-separately tabs the first time they're opened.if (tab === 'analytics' && currentChannelUsername && !_chTabLoaded.analytics) {_chTabLoaded.analytics = true;@@@ -2053,7 +2071,8 @@ function switchChannelTab(tab, btn) {_chTabLoaded.clipsTaken = true;try { loadClipsTaken(currentChannelUsername, { reset: true }); } catch {}}-- if (tab === 'agents' && currentChannelUsername) {++ if (tab === 'agents' && currentChannelUsername && !_chTabLoaded.agents) {++ _chTabLoaded.agents = true;try { loadChannelAgents(currentChannelUsername); } catch {}}if (tab === 'ai-timeline' && currentChannelUsername && !_chTabLoaded.aiTimeline) {@@@ -2065,6 +2084,32 @@ function switchChannelTab(tab, btn) {try { loadChannelMedia(currentChannelUsername); } catch {}}}++// A section whose jump link is hidden (no About, no agents, Media requests off) is hidden with it: it has nothing in it.++function _syncChannelSections() {++ document.querySelectorAll('#ch-tabs .ch-tab[data-tab]').forEach(b => {++ const panel = document.getElementById('ch-panel-' + b.dataset.tab);++ if (panel) panel.hidden = b.style.display === 'none';++ });++}++let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;++function _watchChannelSections() {++ _syncChannelSections();++ const tabs = document.getElementById('ch-tabs');++ if (!_chTabsObs && tabs && window.MutationObserver) {++ _chTabsObs = new MutationObserver(_syncChannelSections);++ _chTabsObs.observe(tabs, { subtree: true, attributes: true, attributeFilter: ['style'] });++ }++ const panels = document.querySelectorAll('#page-channel .ch-tab-panel');++ if (!('IntersectionObserver' in window)) { panels.forEach(p => _ensureChannelSection(p.id.replace('ch-panel-', ''), true)); return; }++ if (!_chSecObs) _chSecObs = new IntersectionObserver((es) => es.forEach(e => { if (e.isIntersecting) _ensureChannelSection(e.target.id.replace('ch-panel-', ''), true); }), { rootMargin: '600px 0px' });++ if (!_chSpyObs) _chSpyObs = new IntersectionObserver((es) => es.forEach(e => { if (e.isIntersecting) _markChannelJump(e.target.id.replace('ch-panel-', '')); }), { rootMargin: '-35% 0px -60% 0px' });++ panels.forEach(p => { _chSecObs.observe(p); _chSpyObs.observe(p); });++ document.querySelectorAll('#page-channel details.ch-fold').forEach(f => {++ if (f._ovBound) return;++ f._ovBound = true;++ f.addEventListener('toggle', () => { if (f.open) _ensureChannelSection(f.dataset.chFold); });++ });++}// The owner's public game progress (roadmap WS-M task 2): GET /api/streams/channel/:username/game answers// { game, url, level, achievements?, playtime_hours? } or 204. Shown as a small badge next to the followers.@@@ -2096,12 +2141,8 @@ function _applyChannelHashTab() {if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;const btn = document.querySelector(`#ch-tabs .ch-tab[data-tab="${h}"]`);if (!btn || btn.style.display === 'none') return; // tab hidden / not present for this channel-- switchChannelTab(h, btn);-- // Jump to the tab strip once the panel has had a beat to render its content.-- setTimeout(() => {-- (document.getElementById('ch-tabs') || document.getElementById('ch-panel-' + h))-- ?.scrollIntoView({ behavior: 'smooth', block: 'start' });-- }, 200);++ // Jump to the section once it has had a beat to render its content.++ setTimeout(() => switchChannelTab(h, btn, true), 200);}let _chTabLoaded = {};let _chAnalyticsDays = 7;@@@ -2171,6 +2212,7 @@ function _resetChannelTabs(ch) {// Default to About when it has real content (bio/panels or a shown AI overview).const defTab = (hasAbout || hasAiOverview) ? 'about' : 'videos';switchChannelTab(defTab, document.querySelector(`#ch-tabs .ch-tab[data-tab="${defTab}"]`));++ _watchChannelSections();// Media tab starts hidden; revealed by _initMediaRequestTab when applicable.// (Controls are no longer a tab — they render in a section under the player.)const medBtn = document.getElementById('ch-tab-btn-media');@@@ -2670,6 +2712,7 @@ function _applyChannelTabMeta(data) {setBadge('ch-tab-badge-clips-taken', data.clipsTakenTotal);setBadge('ch-tab-badge-pastes', data.pasteTotal);setBadge('ch-tab-badge-ai-timeline', data.aiEventTotal);++ { const n = document.getElementById('ch-fold-n-ai-timeline'), b = document.getElementById('ch-tab-badge-ai-timeline'); if (n && b) n.textContent = b.hidden ? '' : `${b.textContent} moments`; }// Never hide tabs for the channel owner — they manage their own content.const isOwner = !!(currentUser && currentChannelUsername && currentUser.username === currentChannelUsername);@@@ -2774,10 +2817,13 @@ function _aboutRowCard(kind, it) {function _renderAboutRecent() {const host = document.getElementById('ch-about-recent');if (!host) return;++ // Sections, not tabs (11-07): the Videos and Clips sections follow About on the page, so these preview rows would++ // show the same cards twice. They were there so an About tab was never a one-line bio over empty space.++ if (document.getElementById('ch-panel-videos')) { host.innerHTML = ''; return; }const r = _aboutRecent;if (!r || r.username !== currentChannelUsername) { host.innerHTML = ''; return; }const row = (title, icon, tab, kind, items) => items.length ? `<section class="ch-about-sec">-- <h3 class="ch-about-h"><i class="fa-solid ${icon}"></i> ${title}<button type="button" class="ch-about-more" onclick="switchChannelTab('${tab}', document.querySelector('.ch-tab[data-tab=${tab}]'))">See all <i class="fa-solid fa-arrow-right"></i></button></h3>++ <h3 class="ch-about-h"><i class="fa-solid ${icon}"></i> ${title}<button type="button" class="ch-about-more" onclick="switchChannelTab('${tab}', document.querySelector('.ch-tab[data-tab=${tab}]'), true)">See all <i class="fa-solid fa-arrow-right"></i></button></h3><div class="ch-about-row">${items.map((it) => _aboutRowCard(kind, it)).join('')}</div></section>` : '';host.innerHTML = row('Latest videos', 'fa-video', 'videos', 'vod', r.vods) + row('Popular clips', 'fa-scissors', 'clips', 'clip', r.clips);@@@ -3168,7 +3214,7 @@ async function confirmMediaRequest() {function editAboutFromTab() {if (!_aboutCanEdit) return;const btn = document.getElementById('ch-tab-btn-about');-- switchChannelTab('about', btn);++ switchChannelTab('about', btn, true);if (!_aboutEditMode) toggleAboutEdit();}function toggleAboutEdit() {nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++/**++ * The channel page's sections are all on the page (owner 2026-10-31: content is never hidden behind a tab to shorten++ * a page). The row above them is a jump bar; AI timeline and Analytics are counted folds; a section is hidden only when++ * its jump link is (it has nothing in it).++ *++ * node test/channel-sections.test.js++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'channel.css'), 'utf8');++++const below = html.slice(html.indexOf('<div class="channel-below-fold">'));++assert.ok(!/role="tab(list|panel)?"/.test(below), 'no tab roles under the player: the row is a jump bar');++assert.match(below, /<nav class="ch-tabs ch-jump" id="ch-tabs" aria-label="Jump to a section">/);++++const SECTIONS = ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'ai-timeline', 'analytics'];++for (const tab of SECTIONS) {++ assert.match(below, new RegExp(`<section class="ch-tab-panel" id="ch-panel-${tab}" aria-labelledby="ch-h-${tab}">`), `${tab} is a section`);++ assert.match(below, new RegExp(`id="ch-h-${tab}"`), `${tab} has its own heading`);++ assert.match(below, new RegExp(`switchChannelTab\\('${tab}', this, true\\)`), `${tab}'s jump link scrolls to it`);++}++for (const fold of ['ai-timeline', 'analytics']) assert.match(below, new RegExp(`<details class="ch-fold" data-ch-fold="${fold}">`), `${fold} is a counted fold`);++++// Every section shows on the channel page; only an empty one (its link hidden) is hidden, and the dashboard keeps its tabs.++assert.match(css, /#page-channel \.ch-tab-panel \{ display: block;/);++assert.ok(!/#page-channel \.ch-tab-panel \{[^}]*display: none/.test(css), 'no channel section is display:none by rule');++assert.match(js, /panel\.hidden = b\.style\.display === 'none';/, 'a section hides only with its jump link');++assert.match(js, /function _watchChannelSections\(\)/);++assert.match(js, /new IntersectionObserver\([\s\S]{0,200}_ensureChannelSection\(e\.target\.id\.replace\('ch-panel-', ''\), true\)/, 'a section loads as it nears the screen');++assert.match(js, /if \(passing && \(tab === 'analytics' \|\| tab === 'ai-timeline'\)\) return;/, 'the folds load when opened, not on the way past');++console.log('channel sections: every section on the page, a jump bar, two counted folds');
@@ -1735,3 +1735,35 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -1735,3 +1735,35 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
}
}
#page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar :is(.emote-picker-btn, .chat-tts-toggle) { color: #fff; }
#page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar :is(.emote-picker-btn, .chat-tts-toggle) { color: #fff; }
}
}
+
+/* ── Sections, not tabs (owner 10-31: content is never hidden behind a tab to shorten a page) ──
+ Every channel section is on the page in the order people look for them; the row above is a sticky jump bar with
+ counts (it follows the section in view); long lists ride sideways on a phone; AI timeline and Analytics are counted
+ folds. Scoped to the channel: the dashboard's settings tabs keep their tabs. */
+#page-channel .channel-below-fold > .container { display: flex; flex-direction: column; }
+#page-channel #ch-tabs { order: 0; position: sticky; top: var(--ovnav-h, 56px); z-index: 6; margin: 0 0 16px; background: var(--bg-primary); }
+#page-channel .ch-tab-panel { display: block; order: 50; margin: 0 0 30px; animation: none; scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px); }
+#page-channel .ch-tab-panel[hidden] { display: none; }
+#page-channel #ch-panel-about { order: 1; } #page-channel #ch-panel-videos { order: 2; } #page-channel #ch-panel-clips { order: 3; }
+#page-channel #ch-panel-agents { order: 4; } #page-channel #ch-panel-clips-taken { order: 5; } #page-channel #ch-panel-pastes { order: 6; }
+#page-channel #ch-panel-media { order: 7; } #page-channel #ch-panel-ai-timeline { order: 8; } #page-channel #ch-panel-analytics { order: 9; }
+#page-channel .ch-sec-h { margin: 0 0 10px; font-size: 1.12rem; font-weight: 800; letter-spacing: -.01em; }
+#page-channel .ch-tab-panel > .ch-panel-hint:first-of-type { margin-top: -4px; }
+#page-channel .ch-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 6px 14px; cursor: pointer; list-style: none;
+ border: 1px solid var(--border); border-radius: 12px; background: color-mix(in srgb, var(--text-primary) 3%, transparent); }
+#page-channel .ch-fold > summary::-webkit-details-marker { display: none; }
+#page-channel .ch-fold > summary .ch-sec-h { margin: 0; }
+#page-channel .ch-fold-n { color: var(--text-muted); font-size: .85rem; font-weight: 700; }
+#page-channel .ch-fold-say { margin-left: auto; color: var(--accent); font-size: .85rem; font-weight: 700; }
+#page-channel .ch-fold[open] > summary { margin-bottom: 12px; }
+#page-channel .ch-fold[open] .ch-fold-say { font-size: 0; }
+#page-channel .ch-fold[open] .ch-fold-say::after { content: "Hide"; font-size: .85rem; }
+@media (hover: hover) { #page-channel .ch-fold > summary:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } }
+@media (max-width: 760px) {
+ /* Long lists ride sideways on a phone: every card is there, the page stays a few screens tall. */
+ #page-channel .ch-tab-panel .stream-grid { display: grid; grid-auto-flow: column; grid-template-columns: none; grid-auto-columns: minmax(220px, 78%);
+ gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; padding-bottom: 6px; scrollbar-width: thin; }
+ #page-channel .ch-tab-panel .stream-grid > * { scroll-snap-align: start; min-width: 0; }
+ #page-channel .ch-tab-panel .stream-grid > p.muted { grid-column: auto; }
+}
+@media (prefers-reduced-motion: reduce) { #page-channel .ch-tab-panel { scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px); } }
@@ -363,32 +363,35 @@
@@ -363,32 +363,35 @@
</div><!-- /.stream-layout -->
</div><!-- /.stream-layout -->
</div>
</div>
- <!-- Channel content — tabbed (below stream) -->
+ <!-- Channel content (below stream): every section on the page, never behind a tab (owner 10-31: content is never
+ hidden to shorten a page). The row is a jump bar with counts; AI timeline and Analytics are counted folds. -->
<div class="channel-below-fold">
<div class="channel-below-fold">
<div class="container">
<div class="container">
- <div class="ch-tabs" id="ch-tabs" role="tablist" aria-label="Channel sections">
- <button class="ch-tab" id="ch-tab-btn-about" role="tab" aria-selected="false" data-tab="about" onclick="switchChannelTab('about', this)" style="display:none"><i class="fa-solid fa-circle-info"></i> About<span class="ch-tab-edit" id="ch-tab-about-edit" role="button" tabindex="0" title="Edit About" onclick="event.stopPropagation(); editAboutFromTab();" style="display:none"><i class="fa-solid fa-pen"></i></span></button>
- <button class="ch-tab active" id="ch-tab-btn-videos" role="tab" aria-selected="true" data-tab="videos" onclick="switchChannelTab('videos', this)"><i class="fa-solid fa-video"></i> Videos<span class="ch-tab-badge" id="ch-tab-badge-videos" hidden></span></button>
- <button class="ch-tab" id="ch-tab-btn-agents" role="tab" aria-selected="false" data-tab="agents" onclick="switchChannelTab('agents', this)" style="display:none"><i class="fa-solid fa-robot"></i> Agents<span class="ch-tab-badge" id="ch-tab-badge-agents" hidden></span></button>
- <button class="ch-tab" id="ch-tab-btn-clips" role="tab" aria-selected="false" data-tab="clips" onclick="switchChannelTab('clips', this)"><i class="fa-solid fa-scissors"></i> Clips<span class="ch-tab-badge" id="ch-tab-badge-clips" hidden></span></button>
- <button class="ch-tab" id="ch-tab-btn-clips-taken" role="tab" aria-selected="false" data-tab="clips-taken" onclick="switchChannelTab('clips-taken', this)"><i class="fa-solid fa-film"></i> Clips Taken<span class="ch-tab-badge" id="ch-tab-badge-clips-taken" hidden></span></button>
- <button class="ch-tab" id="ch-tab-btn-pastes" role="tab" aria-selected="false" data-tab="pastes" onclick="switchChannelTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes<span class="ch-tab-badge" id="ch-tab-badge-pastes" hidden></span></button>
- <button class="ch-tab" id="ch-tab-btn-ai-timeline" role="tab" aria-selected="false" data-tab="ai-timeline" onclick="switchChannelTab('ai-timeline', this)"><i class="fa-solid fa-brain"></i> AI Timeline<span class="ch-tab-badge" id="ch-tab-badge-ai-timeline" hidden></span></button>
- <button class="ch-tab" id="ch-tab-btn-media" role="tab" aria-selected="false" data-tab="media" onclick="switchChannelTab('media', this)" style="display:none"><i class="fa-solid fa-music"></i> Media Request</button>
- <button class="ch-tab" role="tab" aria-selected="false" data-tab="analytics" onclick="switchChannelTab('analytics', this)"><i class="fa-solid fa-chart-line"></i> Analytics</button>
- </div>
+ <nav class="ch-tabs ch-jump" id="ch-tabs" aria-label="Jump to a section">
+ <button class="ch-tab" id="ch-tab-btn-about" data-tab="about" onclick=\"switchChannelTab('about', this, true)\" style="display:none"><i class="fa-solid fa-circle-info"></i> About<span class="ch-tab-edit" id="ch-tab-about-edit" role="button" tabindex="0" title="Edit About" onclick="event.stopPropagation(); editAboutFromTab();" style="display:none"><i class="fa-solid fa-pen"></i></span></button>
+ <button class="ch-tab active" id="ch-tab-btn-videos" data-tab="videos" onclick=\"switchChannelTab('videos', this, true)\"><i class="fa-solid fa-video"></i> Videos<span class="ch-tab-badge" id="ch-tab-badge-videos" hidden></span></button>
+ <button class="ch-tab" id="ch-tab-btn-agents" data-tab="agents" onclick=\"switchChannelTab('agents', this, true)\" style="display:none"><i class="fa-solid fa-robot"></i> Agents<span class="ch-tab-badge" id="ch-tab-badge-agents" hidden></span></button>
+ <button class="ch-tab" id="ch-tab-btn-clips" data-tab="clips" onclick=\"switchChannelTab('clips', this, true)\"><i class="fa-solid fa-scissors"></i> Clips<span class="ch-tab-badge" id="ch-tab-badge-clips" hidden></span></button>
+ <button class="ch-tab" id="ch-tab-btn-clips-taken" data-tab="clips-taken" onclick=\"switchChannelTab('clips-taken', this, true)\"><i class="fa-solid fa-film"></i> Clips Taken<span class="ch-tab-badge" id="ch-tab-badge-clips-taken" hidden></span></button>
+ <button class="ch-tab" id="ch-tab-btn-pastes" data-tab="pastes" onclick=\"switchChannelTab('pastes', this, true)\"><i class="fa-solid fa-paste"></i> Pastes<span class="ch-tab-badge" id="ch-tab-badge-pastes" hidden></span></button>
+ <button class="ch-tab" id="ch-tab-btn-ai-timeline" data-tab="ai-timeline" onclick=\"switchChannelTab('ai-timeline', this, true)\"><i class="fa-solid fa-brain"></i> AI Timeline<span class="ch-tab-badge" id="ch-tab-badge-ai-timeline" hidden></span></button>
+ <button class="ch-tab" id="ch-tab-btn-media" data-tab="media" onclick=\"switchChannelTab('media', this, true)\" style="display:none"><i class="fa-solid fa-music"></i> Media Request</button>
+ <button class="ch-tab" data-tab="analytics" onclick=\"switchChannelTab('analytics', this, true)\"><i class="fa-solid fa-chart-line"></i> Analytics</button>
+ </nav>
<!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. -->
<!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. -->
- <div class="ch-tab-panel" id="ch-panel-agents" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-agents" aria-labelledby="ch-h-agents">
+ <h2 class="ch-sec-h" id="ch-h-agents">Agents</h2>
<p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p>
<p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p>
<h3 class="agent-schedules-title" id="ch-agents-schedules-title" hidden>Coming up</h3>
<h3 class="agent-schedules-title" id="ch-agents-schedules-title" hidden>Coming up</h3>
<div class="agent-schedules" id="ch-agents-schedules"></div>
<div class="agent-schedules" id="ch-agents-schedules"></div>
<div class="agent-cards" id="ch-agents-grid"></div>
<div class="agent-cards" id="ch-agents-grid"></div>
<button class="btn btn-small btn-outline agent-more" id="ch-agents-more" type="button" hidden onclick="loadMoreChannelAgents(this)">More</button>
<button class="btn btn-small btn-outline agent-more" id="ch-agents-more" type="button" hidden onclick="loadMoreChannelAgents(this)">More</button>
- </div>
+ </section>
<!-- Videos -->
<!-- Videos -->
- <div class="ch-tab-panel active" id="ch-panel-videos" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-videos" aria-labelledby="ch-h-videos">
+ <h2 class="ch-sec-h" id="ch-h-videos">Videos</h2>
<div class="ch-panel-bar">
<div class="ch-panel-bar">
<div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>
<div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>
<a id="ch-vods-video-link" class="btn btn-small btn-outline" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists"><i class="fa-solid fa-film"></i> On OpenVibe.Video</a>
<a id="ch-vods-video-link" class="btn btn-small btn-outline" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists"><i class="fa-solid fa-film"></i> On OpenVibe.Video</a>
@@ -396,10 +399,11 @@
@@ -396,10 +399,11 @@
</div>
</div>
<div class="stream-grid" id="ch-vods-grid"><p class="muted">Loading videos…</p></div>
<div class="stream-grid" id="ch-vods-grid"><p class="muted">Loading videos…</p></div>
<div id="ch-vods-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-vods-pagination" class="pastes-pagination" style="display:none"></div>
- </div>
+ </section>
<!-- Clips (clips OF this streamer's streams) -->
<!-- Clips (clips OF this streamer's streams) -->
- <div class="ch-tab-panel" id="ch-panel-clips" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-clips" aria-labelledby="ch-h-clips">
+ <h2 class="ch-sec-h" id="ch-h-clips">Clips</h2>
<p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p>
<p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
@@ -409,10 +413,11 @@
@@ -409,10 +413,11 @@
<div class="stream-grid" id="ch-ai-clips-grid"></div>
<div class="stream-grid" id="ch-ai-clips-grid"></div>
<div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>
</div>
</div>
- </div>
+ </section>
<!-- Clips Taken (clips this streamer made of others) -->
<!-- Clips Taken (clips this streamer made of others) -->
- <div class="ch-tab-panel" id="ch-panel-clips-taken" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-clips-taken" aria-labelledby="ch-h-clips-taken">
+ <h2 class="ch-sec-h" id="ch-h-clips-taken">Clips taken</h2>
<p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p>
<p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p>
<div class="ch-panel-bar">
<div class="ch-panel-bar">
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
@@ -420,24 +425,27 @@
@@ -420,24 +425,27 @@
</div>
</div>
<div class="stream-grid" id="ch-clips-grid"><p class="muted">Loading clips…</p></div>
<div class="stream-grid" id="ch-clips-grid"><p class="muted">Loading clips…</p></div>
<div id="ch-clips-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-clips-pagination" class="pastes-pagination" style="display:none"></div>
- </div>
+ </section>
<!-- Pastes -->
<!-- Pastes -->
- <div class="ch-tab-panel" id="ch-panel-pastes" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-pastes" aria-labelledby="ch-h-pastes">
+ <h2 class="ch-sec-h" id="ch-h-pastes">Pastes</h2>
<div class="ch-panel-bar" id="ch-pastes-header">
<div class="ch-panel-bar" id="ch-pastes-header">
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
</div>
</div>
<div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div>
<div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div>
<div id="ch-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-pastes-pagination" class="pastes-pagination" style="display:none"></div>
- </div>
+ </section>
<!-- Media Request -->
<!-- Media Request -->
- <div class="ch-tab-panel" id="ch-panel-media" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-media" aria-labelledby="ch-h-media">
+ <h2 class="ch-sec-h" id="ch-h-media">Media requests</h2>
<div id="ch-media-content"><div class="loading">Loading…</div></div>
<div id="ch-media-content"><div class="loading">Loading…</div></div>
- </div>
+ </section>
<!-- Analytics -->
<!-- Analytics -->
- <div class="ch-tab-panel" id="ch-panel-analytics" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-analytics" aria-labelledby="ch-h-analytics">
+ <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics">Analytics</h2><span class="ch-fold-say">Show</span></summary>
<div class="ch-panel-bar" id="ch-analytics-header">
<div class="ch-panel-bar" id="ch-analytics-header">
<div class="ch-analytics-period-btns" id="ch-analytics-period-btns">
<div class="ch-analytics-period-btns" id="ch-analytics-period-btns">
<button class="btn btn-sm btn-ghost active" data-days="7">7d</button>
<button class="btn btn-sm btn-ghost active" data-days="7">7d</button>
@@ -463,17 +471,21 @@
@@ -463,17 +471,21 @@
</div>
</div>
</div>
</div>
</div>
</div>
- </div>
+ </details>
+ </section>
<!-- AI Timeline — the streamer's whole AI-observed history -->
<!-- AI Timeline — the streamer's whole AI-observed history -->
- <div class="ch-tab-panel" id="ch-panel-ai-timeline" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-ai-timeline" aria-labelledby="ch-h-ai-timeline">
+ <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline">AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary>
<div id="ch-ai-timeline"><div class="loading">Loading AI timeline…</div></div>
<div id="ch-ai-timeline"><div class="loading">Loading AI timeline…</div></div>
- </div>
+ </details>
+ </section>
<!-- About (weather is now an optional panel inside here) -->
<!-- About (weather is now an optional panel inside here) -->
- <div class="ch-tab-panel" id="ch-panel-about" role="tabpanel">
+ <section class="ch-tab-panel" id="ch-panel-about" aria-labelledby="ch-h-about">
+ <h2 class="ch-sec-h" id="ch-h-about">About</h2>
<div id="ch-about-content" class="ch-about-content"></div>
<div id="ch-about-content" class="ch-about-content"></div>
- </div>
+ </section>
</div>
</div>
</div>
</div>
@@ -2030,16 +2030,34 @@ function loadMoreChannelAgents(button) {
@@ -2030,16 +2030,34 @@ function loadMoreChannelAgents(button) {
try { _agentsPaging.grid.more(); } finally { if (button) button.disabled = false; }
try { _agentsPaging.grid.more(); } finally { if (button) button.disabled = false; }
}
}
-function switchChannelTab(tab, btn) {
+// The channel's sections are all on the page (owner 10-31: content is never hidden behind a tab). The row above them is a
+// jump bar: a press scrolls to the section (and opens a fold); the section loads its data the first time it comes near
+// the screen, or when it is jumped to. Inline handlers keep this name (features.json stubs).
+function switchChannelTab(tab, btn, jump) {
_channelTab = tab;
_channelTab = tab;
+ _markChannelJump(tab);
+ _ensureChannelSection(tab);
+ if (!jump) return;
+ const panel = document.getElementById('ch-panel-' + tab);
+ if (!panel || panel.hidden) return;
+ const fold = panel.querySelector('details.ch-fold');
+ if (fold && !fold.open) fold.open = true;
+ const reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
+ panel.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
+}
+function _markChannelJump(tab) {
document.querySelectorAll('#ch-tabs .ch-tab').forEach(b => {
document.querySelectorAll('#ch-tabs .ch-tab').forEach(b => {
const on = b.dataset.tab === tab;
const on = b.dataset.tab === tab;
b.classList.toggle('active', on);
b.classList.toggle('active', on);
- b.setAttribute('aria-selected', on ? 'true' : 'false');
+ if (on) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
});
});
- document.querySelectorAll('.ch-tab-panel').forEach(p => p.classList.remove('active'));
- const panel = document.getElementById('ch-panel-' + tab);
- if (panel) panel.classList.add('active');
+}
+// A section's data: once, the first time it is near the screen or jumped to. The two folds (AI timeline, Analytics)
+// load when opened, not on the way past; the media queue reloads when it comes back into view (it changes constantly).
+let _chMediaAt = 0;
+function _ensureChannelSection(tab, passing) {
+ if (passing && (tab === 'analytics' || tab === 'ai-timeline')) return;
+ if (tab === 'media') { if (Date.now() - _chMediaAt < 15000) return; _chMediaAt = Date.now(); }
// Lazy-load the fetched-separately tabs the first time they're opened.
// Lazy-load the fetched-separately tabs the first time they're opened.
if (tab === 'analytics' && currentChannelUsername && !_chTabLoaded.analytics) {
if (tab === 'analytics' && currentChannelUsername && !_chTabLoaded.analytics) {
_chTabLoaded.analytics = true;
_chTabLoaded.analytics = true;
@@ -2053,7 +2071,8 @@ function switchChannelTab(tab, btn) {
@@ -2053,7 +2071,8 @@ function switchChannelTab(tab, btn) {
_chTabLoaded.clipsTaken = true;
_chTabLoaded.clipsTaken = true;
try { loadClipsTaken(currentChannelUsername, { reset: true }); } catch {}
try { loadClipsTaken(currentChannelUsername, { reset: true }); } catch {}
}
}
- if (tab === 'agents' && currentChannelUsername) {
+ if (tab === 'agents' && currentChannelUsername && !_chTabLoaded.agents) {
+ _chTabLoaded.agents = true;
try { loadChannelAgents(currentChannelUsername); } catch {}
try { loadChannelAgents(currentChannelUsername); } catch {}
}
}
if (tab === 'ai-timeline' && currentChannelUsername && !_chTabLoaded.aiTimeline) {
if (tab === 'ai-timeline' && currentChannelUsername && !_chTabLoaded.aiTimeline) {
@@ -2065,6 +2084,32 @@ function switchChannelTab(tab, btn) {
@@ -2065,6 +2084,32 @@ function switchChannelTab(tab, btn) {
try { loadChannelMedia(currentChannelUsername); } catch {}
try { loadChannelMedia(currentChannelUsername); } catch {}
}
}
}
}
+// A section whose jump link is hidden (no About, no agents, Media requests off) is hidden with it: it has nothing in it.
+function _syncChannelSections() {
+ document.querySelectorAll('#ch-tabs .ch-tab[data-tab]').forEach(b => {
+ const panel = document.getElementById('ch-panel-' + b.dataset.tab);
+ if (panel) panel.hidden = b.style.display === 'none';
+ });
+}
+let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;
+function _watchChannelSections() {
+ _syncChannelSections();
+ const tabs = document.getElementById('ch-tabs');
+ if (!_chTabsObs && tabs && window.MutationObserver) {
+ _chTabsObs = new MutationObserver(_syncChannelSections);
+ _chTabsObs.observe(tabs, { subtree: true, attributes: true, attributeFilter: ['style'] });
+ }
+ const panels = document.querySelectorAll('#page-channel .ch-tab-panel');
+ if (!('IntersectionObserver' in window)) { panels.forEach(p => _ensureChannelSection(p.id.replace('ch-panel-', ''), true)); return; }
+ if (!_chSecObs) _chSecObs = new IntersectionObserver((es) => es.forEach(e => { if (e.isIntersecting) _ensureChannelSection(e.target.id.replace('ch-panel-', ''), true); }), { rootMargin: '600px 0px' });
+ if (!_chSpyObs) _chSpyObs = new IntersectionObserver((es) => es.forEach(e => { if (e.isIntersecting) _markChannelJump(e.target.id.replace('ch-panel-', '')); }), { rootMargin: '-35% 0px -60% 0px' });
+ panels.forEach(p => { _chSecObs.observe(p); _chSpyObs.observe(p); });
+ document.querySelectorAll('#page-channel details.ch-fold').forEach(f => {
+ if (f._ovBound) return;
+ f._ovBound = true;
+ f.addEventListener('toggle', () => { if (f.open) _ensureChannelSection(f.dataset.chFold); });
+ });
+}
// The owner's public game progress (roadmap WS-M task 2): GET /api/streams/channel/:username/game answers
// The owner's public game progress (roadmap WS-M task 2): GET /api/streams/channel/:username/game answers
// { game, url, level, achievements?, playtime_hours? } or 204. Shown as a small badge next to the followers.
// { game, url, level, achievements?, playtime_hours? } or 204. Shown as a small badge next to the followers.
@@ -2096,12 +2141,8 @@ function _applyChannelHashTab() {
@@ -2096,12 +2141,8 @@ function _applyChannelHashTab() {
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
const btn = document.querySelector(`#ch-tabs .ch-tab[data-tab="${h}"]`);
const btn = document.querySelector(`#ch-tabs .ch-tab[data-tab="${h}"]`);
if (!btn || btn.style.display === 'none') return; // tab hidden / not present for this channel
if (!btn || btn.style.display === 'none') return; // tab hidden / not present for this channel
- switchChannelTab(h, btn);
- // Jump to the tab strip once the panel has had a beat to render its content.
- setTimeout(() => {
- (document.getElementById('ch-tabs') || document.getElementById('ch-panel-' + h))
- ?.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }, 200);
+ // Jump to the section once it has had a beat to render its content.
+ setTimeout(() => switchChannelTab(h, btn, true), 200);
}
}
let _chTabLoaded = {};
let _chTabLoaded = {};
let _chAnalyticsDays = 7;
let _chAnalyticsDays = 7;
@@ -2171,6 +2212,7 @@ function _resetChannelTabs(ch) {
@@ -2171,6 +2212,7 @@ function _resetChannelTabs(ch) {
// Default to About when it has real content (bio/panels or a shown AI overview).
// Default to About when it has real content (bio/panels or a shown AI overview).
const defTab = (hasAbout || hasAiOverview) ? 'about' : 'videos';
const defTab = (hasAbout || hasAiOverview) ? 'about' : 'videos';
switchChannelTab(defTab, document.querySelector(`#ch-tabs .ch-tab[data-tab="${defTab}"]`));
switchChannelTab(defTab, document.querySelector(`#ch-tabs .ch-tab[data-tab="${defTab}"]`));
+ _watchChannelSections();
// Media tab starts hidden; revealed by _initMediaRequestTab when applicable.
// Media tab starts hidden; revealed by _initMediaRequestTab when applicable.
// (Controls are no longer a tab — they render in a section under the player.)
// (Controls are no longer a tab — they render in a section under the player.)
const medBtn = document.getElementById('ch-tab-btn-media');
const medBtn = document.getElementById('ch-tab-btn-media');
@@ -2670,6 +2712,7 @@ function _applyChannelTabMeta(data) {
@@ -2670,6 +2712,7 @@ function _applyChannelTabMeta(data) {
setBadge('ch-tab-badge-clips-taken', data.clipsTakenTotal);
setBadge('ch-tab-badge-clips-taken', data.clipsTakenTotal);
setBadge('ch-tab-badge-pastes', data.pasteTotal);
setBadge('ch-tab-badge-pastes', data.pasteTotal);
setBadge('ch-tab-badge-ai-timeline', data.aiEventTotal);
setBadge('ch-tab-badge-ai-timeline', data.aiEventTotal);
+ { const n = document.getElementById('ch-fold-n-ai-timeline'), b = document.getElementById('ch-tab-badge-ai-timeline'); if (n && b) n.textContent = b.hidden ? '' : `${b.textContent} moments`; }
// Never hide tabs for the channel owner — they manage their own content.
// Never hide tabs for the channel owner — they manage their own content.
const isOwner = !!(currentUser && currentChannelUsername && currentUser.username === currentChannelUsername);
const isOwner = !!(currentUser && currentChannelUsername && currentUser.username === currentChannelUsername);
@@ -2774,10 +2817,13 @@ function _aboutRowCard(kind, it) {
@@ -2774,10 +2817,13 @@ function _aboutRowCard(kind, it) {
function _renderAboutRecent() {
function _renderAboutRecent() {
const host = document.getElementById('ch-about-recent');
const host = document.getElementById('ch-about-recent');
if (!host) return;
if (!host) return;
+ // Sections, not tabs (11-07): the Videos and Clips sections follow About on the page, so these preview rows would
+ // show the same cards twice. They were there so an About tab was never a one-line bio over empty space.
+ if (document.getElementById('ch-panel-videos')) { host.innerHTML = ''; return; }
const r = _aboutRecent;
const r = _aboutRecent;
if (!r || r.username !== currentChannelUsername) { host.innerHTML = ''; return; }
if (!r || r.username !== currentChannelUsername) { host.innerHTML = ''; return; }
const row = (title, icon, tab, kind, items) => items.length ? `<section class="ch-about-sec">
const row = (title, icon, tab, kind, items) => items.length ? `<section class="ch-about-sec">
- <h3 class="ch-about-h"><i class="fa-solid ${icon}"></i> ${title}<button type="button" class="ch-about-more" onclick="switchChannelTab('${tab}', document.querySelector('.ch-tab[data-tab=${tab}]'))">See all <i class="fa-solid fa-arrow-right"></i></button></h3>
+ <h3 class="ch-about-h"><i class="fa-solid ${icon}"></i> ${title}<button type="button" class="ch-about-more" onclick="switchChannelTab('${tab}', document.querySelector('.ch-tab[data-tab=${tab}]'), true)">See all <i class="fa-solid fa-arrow-right"></i></button></h3>
<div class="ch-about-row">${items.map((it) => _aboutRowCard(kind, it)).join('')}</div>
<div class="ch-about-row">${items.map((it) => _aboutRowCard(kind, it)).join('')}</div>
</section>` : '';
</section>` : '';
host.innerHTML = row('Latest videos', 'fa-video', 'videos', 'vod', r.vods) + row('Popular clips', 'fa-scissors', 'clips', 'clip', r.clips);
host.innerHTML = row('Latest videos', 'fa-video', 'videos', 'vod', r.vods) + row('Popular clips', 'fa-scissors', 'clips', 'clip', r.clips);
@@ -3168,7 +3214,7 @@ async function confirmMediaRequest() {
@@ -3168,7 +3214,7 @@ async function confirmMediaRequest() {
function editAboutFromTab() {
function editAboutFromTab() {
if (!_aboutCanEdit) return;
if (!_aboutCanEdit) return;
const btn = document.getElementById('ch-tab-btn-about');
const btn = document.getElementById('ch-tab-btn-about');
- switchChannelTab('about', btn);
+ switchChannelTab('about', btn, true);
if (!_aboutEditMode) toggleAboutEdit();
if (!_aboutEditMode) toggleAboutEdit();
}
}
function toggleAboutEdit() {
function toggleAboutEdit() {
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+/**
+ * The channel page's sections are all on the page (owner 2026-10-31: content is never hidden behind a tab to shorten
+ * a page). The row above them is a jump bar; AI timeline and Analytics are counted folds; a section is hidden only when
+ * its jump link is (it has nothing in it).
+ *
+ * node test/channel-sections.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'channel.css'), 'utf8');
+
+const below = html.slice(html.indexOf('<div class="channel-below-fold">'));
+assert.ok(!/role="tab(list|panel)?"/.test(below), 'no tab roles under the player: the row is a jump bar');
+assert.match(below, /<nav class="ch-tabs ch-jump" id="ch-tabs" aria-label="Jump to a section">/);
+
+const SECTIONS = ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'ai-timeline', 'analytics'];
+for (const tab of SECTIONS) {
+ assert.match(below, new RegExp(`<section class="ch-tab-panel" id="ch-panel-${tab}" aria-labelledby="ch-h-${tab}">`), `${tab} is a section`);
+ assert.match(below, new RegExp(`id="ch-h-${tab}"`), `${tab} has its own heading`);
+ assert.match(below, new RegExp(`switchChannelTab\\('${tab}', this, true\\)`), `${tab}'s jump link scrolls to it`);
+}
+for (const fold of ['ai-timeline', 'analytics']) assert.match(below, new RegExp(`<details class="ch-fold" data-ch-fold="${fold}">`), `${fold} is a counted fold`);
+
+// Every section shows on the channel page; only an empty one (its link hidden) is hidden, and the dashboard keeps its tabs.
+assert.match(css, /#page-channel \.ch-tab-panel \{ display: block;/);
+assert.ok(!/#page-channel \.ch-tab-panel \{[^}]*display: none/.test(css), 'no channel section is display:none by rule');
+assert.match(js, /panel\.hidden = b\.style\.display === 'none';/, 'a section hides only with its jump link');
+assert.match(js, /function _watchChannelSections\(\)/);
+assert.match(js, /new IntersectionObserver\([\s\S]{0,200}_ensureChannelSection\(e\.target\.id\.replace\('ch-panel-', ''\), true\)/, 'a section loads as it nears the screen');
+assert.match(js, /if \(passing && \(tab === 'analytics' \|\| tab === 'ai-timeline'\)\) return;/, 'the folds load when opened, not on the way past');
+console.log('channel sections: every section on the page, a jump bar, two counted folds');