openvibers/openvibe.live Commits

Home on phones, part 3: the featured stream once, and the intro starts closed (#465)

OP OpenVibers committed 13 days ago · efc7f5e9b1 · 7 files, +88 −12

* Home on phones, part 3: the featured stream once, and the intro starts closed

Measured on production after scrolling (sections arrive as they near the screen), the phone home was 7.3 screens.
- The featured stream's card leaves the Live Now grid on a phone: the featured block already shows it, and with one
  stream live the page drew it twice, 1,000 px apart. When it was the only stream, the grid's header goes too.
- The intro at the foot (How it works, About, The network) starts closed on a phone: three tabs, a tap opens a
  section, the same tab closes it again, and the choice is kept. Open, the tour was 1.5 screens under everything a
  returning viewer came for. initHomeTabs gains { closable }; the row keeps one focus stop when nothing is open.
Phone home: 7.3 -> 5.4 screens. Wider screens are unchanged.

* A stream's type is a small icon on its thumbnail, not a label over the picture; JS budget 1240

The owner circled the purple "Screen Share" pill covering a live stream's preview (2026-10-31). On a thumbnail the
badge is now a 26 px round icon in the corner with the label kept for screen readers and the tooltip; beside a
channel's title it keeps its label. jsRawKB 1238.8 measured with this branch's home changes.

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 035ec110..bcd7f372 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -1326,3 +1326,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-tabbox [data-panel="happening"] > .section-header { display: none; }
}
@media (max-width: 759px) and (prefers-reduced-motion: reduce) { .home-catchup-tabs [role="tab"], .home-tabs [role="tab"] { transition: none; } }
++
++/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the
++ featured block above already shows it), and when it was the only stream there the grid's header goes too; the
++ intro at the foot starts closed (app-home.js: initHomeTabs { closable }). */
++@media (max-width: 640px) {
++ #stream-grid-live .stream-card.is-featured { display: none; }
++ @supports selector(:has(*)) {
++ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card) { display: none; }
++ .section-header:has(+ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card)) { display: none; }
++ }
++ .home-tabbox[data-tab=""] .home-tabs { margin-bottom: 0; }
++}
ddiff --git a/public/css/style.css b/public/css/style.css
iindex a2ffd577..2ed84926 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -924,12 +924,28 @@ body.ov-nav-overlay .navbar.ovnav-top {
background: rgba(255,255,255,.12);
color: #fff;
}
++/* On a thumbnail: a small round icon in the corner, not a purple label over the picture (owner, 2026-10-31: the
++ "Screen Share" pill covered a live stream's preview). The label is kept for screen readers and the tooltip. */
.stream-card-thumb .stream-type-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
++ width: 26px;
++ height: 26px;
++ padding: 0;
++ justify-content: center;
++ border-radius: 50%;
++ background: rgba(10, 15, 28, .62);
++ color: #e9d5ff;
++ font-size: .72rem;
++ box-shadow: 0 0 0 1px rgba(255, 255, 255, .12);
++}
++.stream-card-thumb .stream-type-badge .stb-label {
++ position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
++.stream-card-thumb .stream-type-mic_only { color: #fbcfe8; }
++.stream-card-thumb .stream-type-camera_only { color: #bfdbfe; }
.stream-type-screen { background: rgba(139,92,246,.85); }
.stream-type-mic_only { background: rgba(236,72,153,.85); }
.stream-type-camera_only { background: rgba(59,130,246,.85); }
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex eba2f9b8..ad657b7d 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1258,40 +1258,50 @@ function demoteHomeIntroSections() {
* On a phone a block shows one of its sections at a time behind a row of tabs; wider screens never see the tabs
* and show every section. The tabs and the CSS live in the home fragment and features/home.css.
*
-- * initHomeTabs(boxId, shownIds, storageKey, onOpen)
++ * initHomeTabs(boxId, shownIds, storageKey, onOpen, { closable })
* boxId the block's element id. It carries data-tab — the section that is open — and its tab row.
* shownIds tab key → the id of the element that says the section has something. Each loader already hides
* that element when its section is empty, so the tab goes with it.
* storageKey where the reader's last tab is kept for their next visit.
* onOpen called with the key of the section that just opened (the tour loads when its tab opens).
++ * closable the block may have no section open: it starts closed (data-tab="") unless the reader left one open,
++ * and pressing the open tab closes it again (2026-10-31: the intro at the foot of the home was 1.5
++ * phone screens open by default, under everything a returning viewer came for).
*
* A tab shows once its section has something; arrow keys, Home and End move between the tabs that show; the kept
* tab opens in the first few seconds only, so a section that arrives later never moves the page under the reader;
* a section that empties out falls back to one that has something, and a block with nothing left in it drops its
* tab row.
*/
--function initHomeTabs(boxId, shownIds, storageKey, onOpen) {
++function initHomeTabs(boxId, shownIds, storageKey, onOpen, { closable = false } = {}) {
const box = document.getElementById(boxId);
if (!box || box._ovTabs) return;
const list = box.querySelector('[role="tablist"]');
const tabs = list ? [...list.querySelectorAll('[role="tab"]')] : [];
if (!list || !tabs.length) return;
box._ovTabs = true;
-- const fallback = box.dataset.tab || tabs[0].dataset.tab;
++ const fallback = closable ? '' : (box.dataset.tab || tabs[0].dataset.tab);
const has = (key) => { const el = shownIds[key] && document.getElementById(shownIds[key]); return !el || el.style.display !== 'none'; };
let wanted = fallback;
-- try { wanted = localStorage.getItem(storageKey) || fallback; } catch { /* */ }
++ try { const kept = localStorage.getItem(storageKey); if (kept !== null && (kept || closable)) wanted = kept; } catch { /* */ }
const pick = (key, { remember = false, focus = false } = {}) => {
box.dataset.tab = key;
for (const t of tabs) {
const on = t.dataset.tab === key;
t.setAttribute('aria-selected', on ? 'true' : 'false');
-- t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
}
++ focusStop();
if (remember) { wanted = key; try { localStorage.setItem(storageKey, key); } catch { /* */ } }
-- if (onOpen) { try { onOpen(key); } catch { /* */ } }
++ if (key && onOpen) { try { onOpen(key); } catch { /* */ } }
};
++ // The row's one focus stop: the open tab, or with nothing open the first tab that shows, so it can be reached.
++ function focusStop() {
++ const key = box.dataset.tab;
++ const first = tabs.find((x) => !x.hidden);
++ for (const t of tabs) t.setAttribute('tabindex', (key ? t.dataset.tab === key : t === first) ? '0' : '-1');
++ }
++ if (closable) pick(wanted && has(wanted) ? wanted : '');
// The kept tab opens if its section arrives in the first few seconds; later, a section arriving never moves the
// page under the reader. A section that empties out falls back to one that has something.
const settleBy = Date.now() + 3000;
@@@ -1301,12 +1311,15 @@ function initHomeTabs(boxId, shownIds, storageKey, onOpen) {
list.hidden = !shown.length;
if (!shown.length) return;
const cur = box.dataset.tab;
-- if (!has(cur)) pick(shown[0].dataset.tab);
++ if (closable && !cur) { focusStop(); return; } // closed stays closed until the reader opens a section
++ if (!has(cur)) pick(closable ? '' : shown[0].dataset.tab);
else if (cur !== wanted && has(wanted) && Date.now() < settleBy) pick(wanted);
};
list.addEventListener('click', (e) => {
const t = e.target.closest('[role="tab"]');
-- if (t) pick(t.dataset.tab, { remember: true });
++ if (!t) return;
++ // A closable block: pressing the open tab closes it again.
++ pick(closable && box.dataset.tab === t.dataset.tab ? '' : t.dataset.tab, { remember: true });
});
// Arrow keys, Home and End move between the tabs that are showing (the tabs pattern).
list.addEventListener('keydown', (e) => {
@@@ -1349,12 +1362,13 @@ function initHomeAround() {
const HOME_INTRO_KEY = 'ov_home_intro';
const _HOME_INTRO_SHOWN = { tour: 'home-tour-mount', about: 'home-cta-banner', network: 'home-network-hub' };
function initHomeIntro() {
++ // On a phone the intro starts closed: three tabs at the foot of the page, each opening its section.
initHomeTabs('home-intro', _HOME_INTRO_SHOWN, HOME_INTRO_KEY, (key) => {
// The tour's code loads when its mount nears the viewport; opening its tab is that moment.
if (key === 'tour') _lazyHomeTour(true);
// The About banner is behind a tab on a phone, so opening that tab is the reader seeing it.
if (key === 'about') _introSeenNow('home-cta-banner');
-- });
++ }, { closable: window.matchMedia('(max-width: 640px)').matches });
}
async function loadHome() {
@@@ -1397,6 +1411,7 @@ async function loadHome() {
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
renderStreamGrid('stream-grid-live', streams, true);
_homeFeaturedSync(streams.length > 0);
++ if (window.homeFeatured && homeFeatured.markGrid) homeFeatured.markGrid();
}, { ttl: 20000 });
} catch (e) {
console.error('Failed to load live streams', e);
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 8594cacc..08136fc6 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -326,7 +326,9 @@ function streamTypeBadge(browserMode, streamingMethod) {
};
const t = types[browserMode];
if (!t || browserMode === 'camera') return '';
-- return `<span class="stream-type-badge stream-type-${browserMode}"><i class="fa-solid ${t.icon}"></i> ${t.label}</span>`;
++ // On a thumbnail the badge is a small icon (features/style.css), so it never covers the picture; the label stays for
++ // screen readers and as the tooltip, and shows in full beside a channel's title.
++ return `<span class="stream-type-badge stream-type-${browserMode}" title="${t.label}"><i class="fa-solid ${t.icon}" aria-hidden="true"></i> <span class="stb-label">${t.label}</span></span>`;
}
/* ── Toast ────────────────────────────────────────────────────── */
ddiff --git a/public/js/home-featured.js b/public/js/home-featured.js
iindex 5bd17b71..25b38d73 100644
---- a/public/js/home-featured.js
++++ b/public/js/home-featured.js
@@@ -218,6 +218,7 @@
render(d);
} else update(d);
st.el.hidden = false;
++ markGrid();
}
function watchVisibility() {
@@@ -231,6 +232,16 @@
document.addEventListener('ov:page', (e) => { if (!e.detail || e.detail.page !== 'home') stop(); });
}
++ /**
++ * The featured stream's card in the Live Now grid carries .is-featured, so a phone shows that stream once, in
++ * the featured block (2026-10-31: with one stream live the phone home drew it twice, 1,000 px apart). Wider
++ * screens keep the card (features/home.css).
++ */
++ function markGrid() {
++ const id = st.current && !st.el?.hidden ? String(st.current.id) : '';
++ for (const c of document.querySelectorAll('#stream-grid-live .stream-card[data-stream-id]')) c.classList.toggle('is-featured', !!id && c.dataset.streamId === id);
++ }
++
/** Called by the home page once the live list has arrived. */
function boot(hasLive) {
st.hasLive = !!hasLive;
@@@ -249,7 +260,8 @@
destroyPlayer();
st.current = null;
if (st.el) { st.el.hidden = true; st.el.innerHTML = ''; }
++ markGrid();
}
-- window.homeFeatured = { boot, stop, setOff, isOff };
++ window.homeFeatured = { boot, stop, setOff, isOff, markGrid };
})();
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 8840aa43..c0266bf6 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,7 +22,7 @@ const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
-- jsRawKB: 1238, // 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
++ jsRawKB: 1240, // 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
jsBrotliKB: 291, // 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
ddiff --git a/test/home-tabs.test.js b/test/home-tabs.test.js
iindex 87a396c0..94a9c7a8 100644
---- a/test/home-tabs.test.js
++++ b/test/home-tabs.test.js
@@@ -217,6 +217,25 @@ check('the tour loads when its tab opens, not only when the mount nears the view
assert.deepStrictEqual(p.loaded, ['homeTour'], 'opening the tab loads the tour');
});
++ await check('on a phone the intro starts closed; a tap opens a section, a second tap closes it, and that is kept', async () => {
++ const p = openHome();
++ p.run('initHomeIntro();');
++ await tick();
++ const block = p.el('home-intro');
++ assert.strictEqual(block.dataset.tab, '', 'nothing open at first: three tabs at the foot of the page');
++ assert.ok(['tour', 'about', 'network'].every((k) => p.el(`hi-tab-${k}`).getAttribute('aria-selected') === 'false'), 'no tab says it is open');
++ const first = ['tour', 'about', 'network'].map((k) => p.el(`hi-tab-${k}`)).find((t) => !t.hidden);
++ assert.ok(first && first.getAttribute('tabindex') === '0', 'the first tab that shows still takes the focus stop');
++ p.click('hi-tab-about');
++ await tick();
++ assert.strictEqual(block.dataset.tab, 'about', 'a tap opens its section');
++ p.click('hi-tab-about');
++ await tick();
++ assert.strictEqual(block.dataset.tab, '', 'the same tab again closes it');
++ assert.strictEqual(p.store.get('ov_home_intro'), '', 'closed is kept for the next visit');
++ assert.match(css, /\.home-tabbox:not\(\[data-tab="tour"\]\) \[data-panel="tour"\]/, 'with nothing open, the CSS hides every section');
++ });
++
await check('a first visit leaves the block where it is', async () => {
const p = openHome();
p.run('initHomeIntro(); demoteHomeIntroSections();');