openvibers/openvibe.live Commits
Home on phones, part 3: the featured stream once, and the intro starts closed (#465)
Changes
@@@ -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; }++}@@@ -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); }@@@ -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);@@@ -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 ────────────────────────────────────────────────────── */@@@ -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 };})();@@@ -22,7 +22,7 @@ const BUDGETS = {htmlRawKB: 120, // measured 94.9htmlBrotliKB: 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)@@@ -217,6 +217,25 @@ check('the tour loads when its tab opens, not only when the mount nears the viewassert.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();');
@@ -1326,3 +1326,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1326,3 +1326,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-tabbox [data-panel="happening"] > .section-header { display: none; }
.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; } }
@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; }
+}
@@ -924,12 +924,28 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -924,12 +924,28 @@ body.ov-nav-overlay .navbar.ovnav-top {
background: rgba(255,255,255,.12);
background: rgba(255,255,255,.12);
color: #fff;
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 {
.stream-card-thumb .stream-type-badge {
position: absolute;
position: absolute;
top: 8px;
top: 8px;
right: 8px;
right: 8px;
z-index: 2;
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-screen { background: rgba(139,92,246,.85); }
.stream-type-mic_only { background: rgba(236,72,153,.85); }
.stream-type-mic_only { background: rgba(236,72,153,.85); }
.stream-type-camera_only { background: rgba(59,130,246,.85); }
.stream-type-camera_only { background: rgba(59,130,246,.85); }
@@ -1258,40 +1258,50 @@ function demoteHomeIntroSections() {
@@ -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
* 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.
* 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.
* 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
* 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.
* 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.
* 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).
* 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
* 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;
* 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
* a section that empties out falls back to one that has something, and a block with nothing left in it drops its
* tab row.
* tab row.
*/
*/
-function initHomeTabs(boxId, shownIds, storageKey, onOpen) {
+function initHomeTabs(boxId, shownIds, storageKey, onOpen, { closable = false } = {}) {
const box = document.getElementById(boxId);
const box = document.getElementById(boxId);
if (!box || box._ovTabs) return;
if (!box || box._ovTabs) return;
const list = box.querySelector('[role="tablist"]');
const list = box.querySelector('[role="tablist"]');
const tabs = list ? [...list.querySelectorAll('[role="tab"]')] : [];
const tabs = list ? [...list.querySelectorAll('[role="tab"]')] : [];
if (!list || !tabs.length) return;
if (!list || !tabs.length) return;
box._ovTabs = true;
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'; };
const has = (key) => { const el = shownIds[key] && document.getElementById(shownIds[key]); return !el || el.style.display !== 'none'; };
let wanted = fallback;
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 } = {}) => {
const pick = (key, { remember = false, focus = false } = {}) => {
box.dataset.tab = key;
box.dataset.tab = key;
for (const t of tabs) {
for (const t of tabs) {
const on = t.dataset.tab === key;
const on = t.dataset.tab === key;
t.setAttribute('aria-selected', on ? 'true' : 'false');
t.setAttribute('aria-selected', on ? 'true' : 'false');
- t.tabIndex = on ? 0 : -1;
if (on && focus) t.focus();
if (on && focus) t.focus();
}
}
+ focusStop();
if (remember) { wanted = key; try { localStorage.setItem(storageKey, key); } catch { /* */ } }
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
// 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.
// page under the reader. A section that empties out falls back to one that has something.
const settleBy = Date.now() + 3000;
const settleBy = Date.now() + 3000;
@@ -1301,12 +1311,15 @@ function initHomeTabs(boxId, shownIds, storageKey, onOpen) {
@@ -1301,12 +1311,15 @@ function initHomeTabs(boxId, shownIds, storageKey, onOpen) {
list.hidden = !shown.length;
list.hidden = !shown.length;
if (!shown.length) return;
if (!shown.length) return;
const cur = box.dataset.tab;
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);
else if (cur !== wanted && has(wanted) && Date.now() < settleBy) pick(wanted);
};
};
list.addEventListener('click', (e) => {
list.addEventListener('click', (e) => {
const t = e.target.closest('[role="tab"]');
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).
// Arrow keys, Home and End move between the tabs that are showing (the tabs pattern).
list.addEventListener('keydown', (e) => {
list.addEventListener('keydown', (e) => {
@@ -1349,12 +1362,13 @@ function initHomeAround() {
@@ -1349,12 +1362,13 @@ function initHomeAround() {
const HOME_INTRO_KEY = 'ov_home_intro';
const HOME_INTRO_KEY = 'ov_home_intro';
const _HOME_INTRO_SHOWN = { tour: 'home-tour-mount', about: 'home-cta-banner', network: 'home-network-hub' };
const _HOME_INTRO_SHOWN = { tour: 'home-tour-mount', about: 'home-cta-banner', network: 'home-network-hub' };
function initHomeIntro() {
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) => {
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.
// The tour's code loads when its mount nears the viewport; opening its tab is that moment.
if (key === 'tour') _lazyHomeTour(true);
if (key === 'tour') _lazyHomeTour(true);
// The About banner is behind a tab on a phone, so opening that tab is the reader seeing it.
// 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');
if (key === 'about') _introSeenNow('home-cta-banner');
- });
+ }, { closable: window.matchMedia('(max-width: 640px)').matches });
}
}
async function loadHome() {
async function loadHome() {
@@ -1397,6 +1411,7 @@ async function loadHome() {
@@ -1397,6 +1411,7 @@ async function loadHome() {
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
renderStreamGrid('stream-grid-live', streams, true);
renderStreamGrid('stream-grid-live', streams, true);
_homeFeaturedSync(streams.length > 0);
_homeFeaturedSync(streams.length > 0);
+ if (window.homeFeatured && homeFeatured.markGrid) homeFeatured.markGrid();
}, { ttl: 20000 });
}, { ttl: 20000 });
} catch (e) {
} catch (e) {
console.error('Failed to load live streams', e);
console.error('Failed to load live streams', e);
@@ -326,7 +326,9 @@ function streamTypeBadge(browserMode, streamingMethod) {
@@ -326,7 +326,9 @@ function streamTypeBadge(browserMode, streamingMethod) {
};
};
const t = types[browserMode];
const t = types[browserMode];
if (!t || browserMode === 'camera') return '';
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 ────────────────────────────────────────────────────── */
/* ── Toast ────────────────────────────────────────────────────── */
@@ -218,6 +218,7 @@
@@ -218,6 +218,7 @@
render(d);
render(d);
} else update(d);
} else update(d);
st.el.hidden = false;
st.el.hidden = false;
+ markGrid();
}
}
function watchVisibility() {
function watchVisibility() {
@@ -231,6 +232,16 @@
@@ -231,6 +232,16 @@
document.addEventListener('ov:page', (e) => { if (!e.detail || e.detail.page !== 'home') stop(); });
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. */
/** Called by the home page once the live list has arrived. */
function boot(hasLive) {
function boot(hasLive) {
st.hasLive = !!hasLive;
st.hasLive = !!hasLive;
@@ -249,7 +260,8 @@
@@ -249,7 +260,8 @@
destroyPlayer();
destroyPlayer();
st.current = null;
st.current = null;
if (st.el) { st.el.hidden = true; st.el.innerHTML = ''; }
if (st.el) { st.el.hidden = true; st.el.innerHTML = ''; }
+ markGrid();
}
}
- window.homeFeatured = { boot, stop, setOff, isOff };
+ window.homeFeatured = { boot, stop, setOff, isOff, markGrid };
})();
})();
@@ -22,7 +22,7 @@ const BUDGETS = {
@@ -22,7 +22,7 @@ const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
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)
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)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
@@ -217,6 +217,25 @@ check('the tour loads when its tab opens, not only when the mount nears the view
@@ -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');
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 () => {
await check('a first visit leaves the block where it is', async () => {
const p = openHome();
const p = openHome();
p.run('initHomeIntro(); demoteHomeIntroSections();');
p.run('initHomeIntro(); demoteHomeIntroSections();');