openvibers/openvibe.live Commits
Keep Live stats sheet scroll inside the sheet (#721)
Changes
@@@ -723,11 +723,14 @@}.bc-camera-sheet-list {-- overflow-y: auto;++ overflow-y: auto; overscroll-behavior: contain;padding: 12px 14px 16px;display: grid;gap: 10px;}++.bc-camera-sheet-header { touch-action: none; }++#bc-chat-sidebar .chat-messages { overscroll-behavior: contain; }++@media (max-width: 768px) { #bc-chat-sidebar .chat-header { touch-action: none; } }.bc-camera-sheet-empty {min-height: 140px;@@@ -7,7 +7,7 @@}.sn-overlay.is-closing { animation: snFadeOut var(--ov-dur-2, 160ms) ease-in forwards; }.sn-dialog {-- position: relative; width: min(860px, 100%); max-height: calc(100dvh - 32px); overflow: auto;++ position: relative; width: min(860px, 100%); max-height: calc(100dvh - 32px); overflow: auto; overscroll-behavior: contain;background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px;padding: 18px 20px 14px; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);animation: snPop var(--ov-dur-2, 240ms) var(--ov-ease-out, cubic-bezier(0.2, 0.9, 0.3, 1.15));@@@ -114,7 +114,7 @@@media (max-width: 640px) {.sn-overlay { padding: 0; align-items: flex-end; }.sn-dialog { border-radius: 20px 20px 0 0; max-height: 92dvh; padding: 14px 14px 12px; }-- .sn-head { grid-template-columns: 1fr auto; }++ .sn-head { grid-template-columns: 1fr auto; touch-action: none; }.sn-prev, .sn-next { display: none; }.sn-head h3 { font-size: 1.1rem; }.sn-table-btn { margin-left: 0; }@@@ -4702,6 +4702,63 @@ function _updatePlatformViewerBadges(places) {/** Toggle broadcast page mobile chat bottom sheet */let _bcMobileChatOpen = false;++const _bcSheets = new Set();++const _bcSheetOpeners = new Map();++let _bcPageBeforeSheet = null;++let _bcSheetKeysBound = false;++function _bcLockSheet(id, opener) {++ if (_bcSheets.has(id)) return;++ if (!_bcPageBeforeSheet) {++ const html = document.documentElement, body = document.body, y = window.scrollY;++ const gap = Math.max(0, window.innerWidth - html.clientWidth);++ _bcPageBeforeSheet = { y, overflow: html.style.overflow, padding: html.style.paddingRight,++ position: body.style.position, top: body.style.top, left: body.style.left, right: body.style.right, width: body.style.width };++ if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;++ html.style.overflow = 'hidden';++ body.style.position = 'fixed'; body.style.top = `-${y}px`;++ body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';++ }++ _bcSheets.add(id); _bcSheetOpeners.set(id, opener);++ if (!_bcSheetKeysBound) {++ _bcSheetKeysBound = true;++ document.addEventListener('keydown', (e) => {++ if (e.key !== 'Escape') return;++ if (_bcSheets.has('camera')) { e.preventDefault(); closeCameraSwitcher(); }++ else if (_bcSheets.has('chat')) { e.preventDefault(); toggleBroadcastMobileChat(); }++ }, true);++ }++}++function _bcUnlockSheet(id) {++ if (!_bcSheets.delete(id)) return;++ const opener = _bcSheetOpeners.get(id); _bcSheetOpeners.delete(id);++ if (!_bcSheets.size && _bcPageBeforeSheet) {++ const s = _bcPageBeforeSheet, html = document.documentElement, body = document.body;++ _bcPageBeforeSheet = null;++ html.style.overflow = s.overflow; html.style.paddingRight = s.padding;++ body.style.position = s.position; body.style.top = s.top;++ body.style.left = s.left; body.style.right = s.right; body.style.width = s.width;++ window.scrollTo(0, s.y);++ }++ if (opener?.isConnected) opener.focus({ preventScroll: true });++}++function _bcDragSheet(head, panel, close) {++ if (!head || head.__bcDragSheet) return;++ head.__bcDragSheet = true;++ let start = null;++ head.addEventListener('pointerdown', (e) => {++ if (e.pointerType !== 'touch' || e.target.closest('button, a, input')) return;++ start = { id: e.pointerId, y: e.clientY };++ try { head.setPointerCapture(e.pointerId); } catch (_) { /* interrupted gesture */ }++ });++ head.addEventListener('pointermove', (e) => { if (start && start.id === e.pointerId) panel.style.translate = `0 ${Math.max(0, e.clientY - start.y)}px`; });++ const end = (e) => {++ if (!start || start.id !== e.pointerId) return;++ const dy = e.clientY - start.y; start = null; panel.style.translate = '';++ if (dy > 90) close();++ };++ head.addEventListener('pointerup', end);++ head.addEventListener('pointercancel', end);++}function toggleBroadcastMobileChat() {const sidebar = document.getElementById('bc-chat-sidebar');const fab = document.getElementById('bc-mobile-chat-toggle');@@@ -4709,6 +4766,10 @@ function toggleBroadcastMobileChat() {_bcMobileChatOpen = !_bcMobileChatOpen;sidebar.classList.toggle('mobile-chat-open', _bcMobileChatOpen);document.body.classList.toggle('mobile-chat-visible', _bcMobileChatOpen);++ if (_bcMobileChatOpen) {++ _bcLockSheet('chat', fab);++ _bcDragSheet(sidebar.querySelector('.chat-header'), sidebar, toggleBroadcastMobileChat);++ } else _bcUnlockSheet('chat');if (fab) {const icon = fab.querySelector('i');if (icon) icon.className = _bcMobileChatOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-comment';@@@ -4801,6 +4862,7 @@ function _refreshVerticalPreview() {function closeCameraSwitcher() {const sheet = document.getElementById('bc-camera-sheet');if (sheet) sheet.style.display = 'none';++ _bcUnlockSheet('camera');}function _renderCameraSwitcherChoices(devices, activeCameraId) {@@@ -4842,6 +4904,8 @@ async function openCameraSwitcher() {if (!sheet || !list) return;sheet.style.display = 'flex';++ _bcLockSheet('camera', document.getElementById('bc-btn-switch-cam'));++ _bcDragSheet(sheet.querySelector('.bc-camera-sheet-header'), sheet.querySelector('.bc-camera-sheet-panel'), closeCameraSwitcher);list.innerHTML = '<div class="bc-camera-sheet-empty"><i class="fa-solid fa-spinner fa-spin"></i><span>Loading cameras…</span></div>';try {@@@ -68,7 +68,29 @@return { top: Math.ceil(max / step - 1e-9) * step, step };};-- const state = { overlay: null, metrics: [], index: 0, days: 30, view: 'daily', table: false, data: null, req: 0, lastFocus: null, ro: null };++ const state = { overlay: null, metrics: [], index: 0, days: 30, view: 'daily', table: false, data: null, req: 0, lastFocus: null, ro: null, scroll: null };++++ function lockPage() {++ const html = document.documentElement, body = document.body;++ const y = window.scrollY;++ const gap = Math.max(0, window.innerWidth - html.clientWidth);++ state.scroll = { y, htmlOverflow: html.style.overflow, htmlPadding: html.style.paddingRight,++ bodyPosition: body.style.position, bodyTop: body.style.top, bodyLeft: body.style.left,++ bodyRight: body.style.right, bodyWidth: body.style.width };++ if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;++ html.style.overflow = 'hidden';++ body.style.position = 'fixed'; body.style.top = `-${y}px`;++ body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';++ }++ function unlockPage() {++ const s = state.scroll; if (!s) return;++ state.scroll = null;++ const html = document.documentElement, body = document.body;++ html.style.overflow = s.htmlOverflow; html.style.paddingRight = s.htmlPadding;++ body.style.position = s.bodyPosition; body.style.top = s.bodyTop;++ body.style.left = s.bodyLeft; body.style.right = s.bodyRight; body.style.width = s.bodyWidth;++ window.scrollTo(0, s.y);++ }/** Every chartable chip on the hero, in board order, deduplicated by metric. */function collectMetrics() {@@@ -109,6 +131,7 @@document.removeEventListener('keydown', onKey, true);const ov = state.overlay;state.overlay = null;++ unlockPage();ov.classList.add('is-closing');setTimeout(() => ov.remove(), 160);try { state.lastFocus?.focus?.({ preventScroll: true }); } catch { /* */ }@@@ -172,6 +195,7 @@</div>`;document.body.appendChild(ov);state.overlay = ov;++ lockPage();ov.addEventListener('click', (e) => { if (e.target === ov) close(); });ov.querySelector('.sn-close').addEventListener('click', close);ov.querySelector('.sn-prev').addEventListener('click', () => step(-1));@@@ -189,6 +213,24 @@sx = null;if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);}, { passive: true });++ // Pulling the sheet's header down closes it; the chart and table keep their own scroll.++ const dragHead = ov.querySelector('.sn-head');++ let drag = null;++ dragHead.addEventListener('pointerdown', (e) => {++ if (e.pointerType !== 'touch' || e.target.closest('button, a, input')) return;++ drag = { id: e.pointerId, y: e.clientY };++ try { dragHead.setPointerCapture(e.pointerId); } catch (_) { /* synthetic pointer or interrupted gesture */ }++ });++ dragHead.addEventListener('pointermove', (e) => {++ if (drag && drag.id === e.pointerId) head.style.translate = `0 ${Math.max(0, e.clientY - drag.y)}px`;++ });++ const endDrag = (e) => {++ if (!drag || drag.id !== e.pointerId) return;++ const dy = e.clientY - drag.y; drag = null; head.style.translate = '';++ if (dy > 90) close();++ };++ dragHead.addEventListener('pointerup', endDrag);++ dragHead.addEventListener('pointercancel', endDrag);const plot = ov.querySelector('.sn-plot');// Only a chart that has points: an error answer has none, and redrawing it threw on every resize.state.ro = new ResizeObserver(() => { if (state.data?.points && !state.table) drawChart(); });nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('openvibe-shared/browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('broadcast-sheets-chrome: skipped (no Chrome)');++ process.exit(0);++}++const source = fs.readFileSync(path.join(__dirname, '..', 'public/js/broadcast.js'), 'utf8');++function extract(name) {++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(source);++ assert.ok(m, `${name} exists`);++ let i = source.indexOf('{', m.index + m[0].length), depth = 0;++ for (; i < source.length; i++) {++ if (source[i] === '{') depth++;++ else if (source[i] === '}' && --depth === 0) return source.slice(m.index, i + 1);++ }++ throw new Error(`${name} is unbalanced`);++}++const script = `let _bcMobileChatOpen=false;const _bcSheets=new Set();const _bcSheetOpeners=new Map();let _bcPageBeforeSheet=null;let _bcSheetKeysBound=false;\n${[' _bcLockSheet', '_bcUnlockSheet', '_bcDragSheet', 'toggleBroadcastMobileChat', 'closeCameraSwitcher'].map((name) => extract(name.trim())).join('\n')}`;++const page = `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>++body{margin:0;background:#111827;color:white}main{height:1800px}#bc-chat-sidebar{position:fixed;bottom:0;left:0;right:0;height:400px;background:#202b42;transform:translateY(100%)}#bc-chat-sidebar.mobile-chat-open{transform:none}.chat-header{height:50px;touch-action:none}.chat-messages{height:280px;overflow:auto;overscroll-behavior:contain}#bc-camera-sheet{position:fixed;inset:0;background:#0008;align-items:end}.bc-camera-sheet-panel{height:350px;background:#27344a;width:100%}.bc-camera-sheet-header{height:55px;touch-action:none}++</style><button id="bc-mobile-chat-toggle"><i></i>Chat</button><button id="bc-btn-switch-cam">Camera</button><main>Broadcast</main><div id="bc-chat-sidebar"><div class="chat-header">Chat <button onclick="toggleBroadcastMobileChat()">Close</button></div><div class="chat-messages" id="bc-chat-messages"><div style="height:1000px">Messages</div></div></div><span id="bc-mobile-chat-badge">2</span><div id="bc-camera-sheet" style="display:none"><div class="bc-camera-sheet-panel"><div class="bc-camera-sheet-header">Cameras <button onclick="closeCameraSwitcher()">Close</button></div></div></div><script src="/snippet.js"></script>`;++const server = http.createServer((req, res) => {++ if (req.url === '/snippet.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(script); }++ res.writeHead(200, { 'content-type': 'text/html' }); res.end(page);++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-broadcast-sheet-'));++ const browser = await h.launch({ tmpDir: tmp });++ try {++ const context = await browser.newContext(), p = await h.openPage(browser, context, { width: 390, height: 844 });++ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });++ await p.evaluate('scrollTo(0,400);window.__before=scrollY;toggleBroadcastMobileChat()');++ assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'chat locks the page');++ assert.strictEqual(await p.evaluate('document.body.style.top'), await p.evaluate('`${-__before}px`'), 'chat retains page position');++ await p.evaluate('document.querySelector("#bc-chat-messages").scrollTop=100');++ assert.strictEqual(await p.evaluate('document.querySelector("#bc-chat-messages").scrollTop'), 100, 'chat scrolls');++ await p.evaluate(`(() => {const head=document.querySelector('.chat-header');const fire=(type,y)=>head.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerType:'touch',pointerId:5,clientY:y}));fire('pointerdown',200);fire('pointermove',320);fire('pointerup',320)})()`);++ assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'header swipe closes chat and unlocks');++ assert.strictEqual(await p.evaluate('Math.round(scrollY)'), await p.evaluate('Math.round(__before)'), 'chat restores scroll');++ await p.evaluate('document.querySelector("#bc-camera-sheet").style.display="flex";_bcLockSheet("camera",document.querySelector("#bc-btn-switch-cam"))');++ await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });++ await p.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });++ assert.strictEqual(await p.evaluate('document.querySelector("#bc-camera-sheet").style.display'), 'none', 'Escape closes camera sheet');++ assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'camera close unlocks');++ await p.close();++ console.log('broadcast-sheets-chrome: chat scroll and swipe, camera Escape and page lock ok');++ } finally { await browser.close(); server.close(); fs.rmSync(tmp, { recursive: true, force: true }); }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -94,15 +94,19 @@ console.log('OK the layout at phone widths: stacked, scrollable, safe-area aware{const els = {'bc-mobile-chat-toggle': { style: { display: 'none' }, querySelector: () => ({ className: '' }) },-- 'bc-chat-sidebar': { classList: classList() },++ 'bc-chat-sidebar': { classList: classList(), querySelector: () => ({}) },'bc-chat-messages': { scrollTop: 0, scrollHeight: 900 },'bc-mobile-chat-badge': { style: {}, textContent: '3' },};++ const locks = [];const fx = {document: { getElementById: (id) => els[id] || null, body: { classList: classList() } },window: { innerWidth: 390 },broadcastState: { streams: new Map() },_bcMobileChatOpen: false,++ _bcLockSheet: (id) => locks.push('open:' + id),++ _bcUnlockSheet: (id) => locks.push('close:' + id),++ _bcDragSheet: () => {},};const f = run([extract(bc, 'updateBroadcastMobileChatFab'), extract(bc, 'toggleBroadcastMobileChat')].join('\n'), ['updateBroadcastMobileChatFab', 'toggleBroadcastMobileChat'], fx);f.updateBroadcastMobileChatFab();@@@ -115,7 +119,10 @@ console.log('OK the layout at phone widths: stacked, scrollable, safe-area awareassert.strictEqual(els['bc-mobile-chat-toggle'].style.display, 'none', 'a desktop has the sidebar');f.toggleBroadcastMobileChat();assert.ok(els['bc-chat-sidebar'].classList.contains('mobile-chat-open') && fx.document.body.classList.contains('mobile-chat-visible'));++ assert.deepStrictEqual(locks, ['open:chat'], 'phone chat locks the page');assert.deepStrictEqual([els['bc-chat-messages'].scrollTop, els['bc-mobile-chat-badge'].textContent], [900, '0'], 'opens at the newest line, unread cleared');++ f.toggleBroadcastMobileChat();++ assert.deepStrictEqual(locks, ['open:chat', 'close:chat'], 'closing chat unlocks the page');assert.match(fragment, /id="bc-mobile-chat-toggle" onclick="toggleBroadcastMobileChat\(\)"/);assert.match(bc, /window\.addEventListener\('resize'[\s\S]{0,200}updateBroadcastMobileChatFab\(\);/, 'rotation and resizing re-decide');console.log('OK chat button: only live on a phone, opens the sheet');nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('openvibe-shared/browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('stats-nerds-sheet-chrome: skipped (no Chrome)');++ process.exit(0);++}++const root = path.join(__dirname, '..');++const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>++:root{color-scheme:dark;--bg-card:#151923;--border:#343947;--text-primary:#f0f2f6;--text-secondary:#b5becd;--text-muted:#9aa5b8;--accent:#68a6ff;--bg-secondary:#222838}++body{margin:0;background:#10141d;color:var(--text-primary);font:15px system-ui}button{cursor:pointer}#page{height:1800px;padding:20px;box-sizing:border-box}++</style><link rel="stylesheet" href="/stats.css"><main id="page"><div id="hero-stats"><button class="hero-stat" data-metric="users" data-tip='{"label":"People","desc":"People on OpenVibe"}'><span class="hero-stat-num" data-n="1200">1,200</span></button></div></main>++<script>window.api=async()=>({kind:'count',before:100,total:735,prev_total:650,points:Array.from({length:30},(_,i)=>({day:'2026-10-'+String(i+1).padStart(2,'0'),value:i+10}))});</script>++<script src="/stats.js"></script><script>document.querySelector('.hero-stat').onclick=e=>openStatsNerds(e.currentTarget)</script>`;++const server = http.createServer((req, res) => {++ const f = req.url === '/stats.css' ? 'public/css/features/stats.css' : req.url === '/stats.js' ? 'public/js/stats-nerds.js' : null;++ if (f) { res.writeHead(200, { 'content-type': f.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, f))); }++ res.writeHead(200, { 'content-type': 'text/html' }); res.end(html);++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-stats-sheet-'));++ const browser = await h.launch({ tmpDir: tmp });++ try {++ const context = await browser.newContext();++ for (const width of [390, 1280]) {++ const p = await h.openPage(browser, context, { width, height: 900 });++ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });++ await p.evaluate('document.querySelector(".hero-stat").focus({preventScroll:true});scrollTo(0,400);window.__before=scrollY;document.querySelector(".hero-stat").click()');++ await new Promise((ok) => setTimeout(ok, 100));++ assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', `${width}: page locked`);++ assert.strictEqual(await p.evaluate('document.body.style.top'), await p.evaluate('`${-__before}px`'), `${width}: position kept`);++ if (process.env.OV_SHEETS_SHOTS) {++ fs.mkdirSync(process.env.OV_SHEETS_SHOTS, { recursive: true });++ const shot = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });++ fs.writeFileSync(path.join(process.env.OV_SHEETS_SHOTS, `live-stats-${width}.png`), Buffer.from(shot.data, 'base64'));++ }++ await p.evaluate('document.querySelector(".sn-dialog").appendChild(Object.assign(document.createElement("div"),{style:"height:1000px"}))');++ await p.evaluate('document.querySelector(".sn-dialog").scrollTop=100');++ assert.ok(await p.evaluate('document.querySelector(".sn-dialog").scrollTop') > 0, `${width}: sheet scrolls`);++ await p.evaluate('document.querySelector(".sn-close").click()');++ assert.strictEqual(await p.evaluate('Math.round(scrollY)'), await p.evaluate('Math.round(__before)'), `${width}: position restored`);++ assert.strictEqual(await p.evaluate('document.activeElement.className'), 'hero-stat', `${width}: focus restored`);++ if (width === 390) {++ await p.evaluate('document.querySelector(".hero-stat").click()');++ await p.evaluate(`(() => { const head=document.querySelector('.sn-head');++ const fire=(type,y)=>head.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:6,pointerType:'touch',clientY:y}));++ fire('pointerdown',200);fire('pointermove',320);fire('pointerup',320); })()`);++ assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'phone: header swipe closes and unlocks');++ }++ await p.close();++ }++ console.log('stats-nerds-sheet-chrome: phone and desktop lock, sheet scroll, position and focus ok');++ } finally { await browser.close(); server.close(); fs.rmSync(tmp, { recursive: true, force: true }); }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -723,11 +723,14 @@
@@ -723,11 +723,14 @@
}
}
.bc-camera-sheet-list {
.bc-camera-sheet-list {
- overflow-y: auto;
+ overflow-y: auto; overscroll-behavior: contain;
padding: 12px 14px 16px;
padding: 12px 14px 16px;
display: grid;
display: grid;
gap: 10px;
gap: 10px;
}
}
+.bc-camera-sheet-header { touch-action: none; }
+#bc-chat-sidebar .chat-messages { overscroll-behavior: contain; }
+@media (max-width: 768px) { #bc-chat-sidebar .chat-header { touch-action: none; } }
.bc-camera-sheet-empty {
.bc-camera-sheet-empty {
min-height: 140px;
min-height: 140px;
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
}
}
.sn-overlay.is-closing { animation: snFadeOut var(--ov-dur-2, 160ms) ease-in forwards; }
.sn-overlay.is-closing { animation: snFadeOut var(--ov-dur-2, 160ms) ease-in forwards; }
.sn-dialog {
.sn-dialog {
- position: relative; width: min(860px, 100%); max-height: calc(100dvh - 32px); overflow: auto;
+ position: relative; width: min(860px, 100%); max-height: calc(100dvh - 32px); overflow: auto; overscroll-behavior: contain;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px;
padding: 18px 20px 14px; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
padding: 18px 20px 14px; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
animation: snPop var(--ov-dur-2, 240ms) var(--ov-ease-out, cubic-bezier(0.2, 0.9, 0.3, 1.15));
animation: snPop var(--ov-dur-2, 240ms) var(--ov-ease-out, cubic-bezier(0.2, 0.9, 0.3, 1.15));
@@ -114,7 +114,7 @@
@@ -114,7 +114,7 @@
@media (max-width: 640px) {
@media (max-width: 640px) {
.sn-overlay { padding: 0; align-items: flex-end; }
.sn-overlay { padding: 0; align-items: flex-end; }
.sn-dialog { border-radius: 20px 20px 0 0; max-height: 92dvh; padding: 14px 14px 12px; }
.sn-dialog { border-radius: 20px 20px 0 0; max-height: 92dvh; padding: 14px 14px 12px; }
- .sn-head { grid-template-columns: 1fr auto; }
+ .sn-head { grid-template-columns: 1fr auto; touch-action: none; }
.sn-prev, .sn-next { display: none; }
.sn-prev, .sn-next { display: none; }
.sn-head h3 { font-size: 1.1rem; }
.sn-head h3 { font-size: 1.1rem; }
.sn-table-btn { margin-left: 0; }
.sn-table-btn { margin-left: 0; }
@@ -4702,6 +4702,63 @@ function _updatePlatformViewerBadges(places) {
@@ -4702,6 +4702,63 @@ function _updatePlatformViewerBadges(places) {
/** Toggle broadcast page mobile chat bottom sheet */
/** Toggle broadcast page mobile chat bottom sheet */
let _bcMobileChatOpen = false;
let _bcMobileChatOpen = false;
+const _bcSheets = new Set();
+const _bcSheetOpeners = new Map();
+let _bcPageBeforeSheet = null;
+let _bcSheetKeysBound = false;
+function _bcLockSheet(id, opener) {
+ if (_bcSheets.has(id)) return;
+ if (!_bcPageBeforeSheet) {
+ const html = document.documentElement, body = document.body, y = window.scrollY;
+ const gap = Math.max(0, window.innerWidth - html.clientWidth);
+ _bcPageBeforeSheet = { y, overflow: html.style.overflow, padding: html.style.paddingRight,
+ position: body.style.position, top: body.style.top, left: body.style.left, right: body.style.right, width: body.style.width };
+ if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;
+ html.style.overflow = 'hidden';
+ body.style.position = 'fixed'; body.style.top = `-${y}px`;
+ body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';
+ }
+ _bcSheets.add(id); _bcSheetOpeners.set(id, opener);
+ if (!_bcSheetKeysBound) {
+ _bcSheetKeysBound = true;
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape') return;
+ if (_bcSheets.has('camera')) { e.preventDefault(); closeCameraSwitcher(); }
+ else if (_bcSheets.has('chat')) { e.preventDefault(); toggleBroadcastMobileChat(); }
+ }, true);
+ }
+}
+function _bcUnlockSheet(id) {
+ if (!_bcSheets.delete(id)) return;
+ const opener = _bcSheetOpeners.get(id); _bcSheetOpeners.delete(id);
+ if (!_bcSheets.size && _bcPageBeforeSheet) {
+ const s = _bcPageBeforeSheet, html = document.documentElement, body = document.body;
+ _bcPageBeforeSheet = null;
+ html.style.overflow = s.overflow; html.style.paddingRight = s.padding;
+ body.style.position = s.position; body.style.top = s.top;
+ body.style.left = s.left; body.style.right = s.right; body.style.width = s.width;
+ window.scrollTo(0, s.y);
+ }
+ if (opener?.isConnected) opener.focus({ preventScroll: true });
+}
+function _bcDragSheet(head, panel, close) {
+ if (!head || head.__bcDragSheet) return;
+ head.__bcDragSheet = true;
+ let start = null;
+ head.addEventListener('pointerdown', (e) => {
+ if (e.pointerType !== 'touch' || e.target.closest('button, a, input')) return;
+ start = { id: e.pointerId, y: e.clientY };
+ try { head.setPointerCapture(e.pointerId); } catch (_) { /* interrupted gesture */ }
+ });
+ head.addEventListener('pointermove', (e) => { if (start && start.id === e.pointerId) panel.style.translate = `0 ${Math.max(0, e.clientY - start.y)}px`; });
+ const end = (e) => {
+ if (!start || start.id !== e.pointerId) return;
+ const dy = e.clientY - start.y; start = null; panel.style.translate = '';
+ if (dy > 90) close();
+ };
+ head.addEventListener('pointerup', end);
+ head.addEventListener('pointercancel', end);
+}
function toggleBroadcastMobileChat() {
function toggleBroadcastMobileChat() {
const sidebar = document.getElementById('bc-chat-sidebar');
const sidebar = document.getElementById('bc-chat-sidebar');
const fab = document.getElementById('bc-mobile-chat-toggle');
const fab = document.getElementById('bc-mobile-chat-toggle');
@@ -4709,6 +4766,10 @@ function toggleBroadcastMobileChat() {
@@ -4709,6 +4766,10 @@ function toggleBroadcastMobileChat() {
_bcMobileChatOpen = !_bcMobileChatOpen;
_bcMobileChatOpen = !_bcMobileChatOpen;
sidebar.classList.toggle('mobile-chat-open', _bcMobileChatOpen);
sidebar.classList.toggle('mobile-chat-open', _bcMobileChatOpen);
document.body.classList.toggle('mobile-chat-visible', _bcMobileChatOpen);
document.body.classList.toggle('mobile-chat-visible', _bcMobileChatOpen);
+ if (_bcMobileChatOpen) {
+ _bcLockSheet('chat', fab);
+ _bcDragSheet(sidebar.querySelector('.chat-header'), sidebar, toggleBroadcastMobileChat);
+ } else _bcUnlockSheet('chat');
if (fab) {
if (fab) {
const icon = fab.querySelector('i');
const icon = fab.querySelector('i');
if (icon) icon.className = _bcMobileChatOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-comment';
if (icon) icon.className = _bcMobileChatOpen ? 'fa-solid fa-xmark' : 'fa-solid fa-comment';
@@ -4801,6 +4862,7 @@ function _refreshVerticalPreview() {
@@ -4801,6 +4862,7 @@ function _refreshVerticalPreview() {
function closeCameraSwitcher() {
function closeCameraSwitcher() {
const sheet = document.getElementById('bc-camera-sheet');
const sheet = document.getElementById('bc-camera-sheet');
if (sheet) sheet.style.display = 'none';
if (sheet) sheet.style.display = 'none';
+ _bcUnlockSheet('camera');
}
}
function _renderCameraSwitcherChoices(devices, activeCameraId) {
function _renderCameraSwitcherChoices(devices, activeCameraId) {
@@ -4842,6 +4904,8 @@ async function openCameraSwitcher() {
@@ -4842,6 +4904,8 @@ async function openCameraSwitcher() {
if (!sheet || !list) return;
if (!sheet || !list) return;
sheet.style.display = 'flex';
sheet.style.display = 'flex';
+ _bcLockSheet('camera', document.getElementById('bc-btn-switch-cam'));
+ _bcDragSheet(sheet.querySelector('.bc-camera-sheet-header'), sheet.querySelector('.bc-camera-sheet-panel'), closeCameraSwitcher);
list.innerHTML = '<div class="bc-camera-sheet-empty"><i class="fa-solid fa-spinner fa-spin"></i><span>Loading cameras…</span></div>';
list.innerHTML = '<div class="bc-camera-sheet-empty"><i class="fa-solid fa-spinner fa-spin"></i><span>Loading cameras…</span></div>';
try {
try {
@@ -68,7 +68,29 @@
@@ -68,7 +68,29 @@
return { top: Math.ceil(max / step - 1e-9) * step, step };
return { top: Math.ceil(max / step - 1e-9) * step, step };
};
};
- const state = { overlay: null, metrics: [], index: 0, days: 30, view: 'daily', table: false, data: null, req: 0, lastFocus: null, ro: null };
+ const state = { overlay: null, metrics: [], index: 0, days: 30, view: 'daily', table: false, data: null, req: 0, lastFocus: null, ro: null, scroll: null };
+
+ function lockPage() {
+ const html = document.documentElement, body = document.body;
+ const y = window.scrollY;
+ const gap = Math.max(0, window.innerWidth - html.clientWidth);
+ state.scroll = { y, htmlOverflow: html.style.overflow, htmlPadding: html.style.paddingRight,
+ bodyPosition: body.style.position, bodyTop: body.style.top, bodyLeft: body.style.left,
+ bodyRight: body.style.right, bodyWidth: body.style.width };
+ if (gap) html.style.paddingRight = `${(parseFloat(getComputedStyle(html).paddingRight) || 0) + gap}px`;
+ html.style.overflow = 'hidden';
+ body.style.position = 'fixed'; body.style.top = `-${y}px`;
+ body.style.left = '0'; body.style.right = '0'; body.style.width = '100%';
+ }
+ function unlockPage() {
+ const s = state.scroll; if (!s) return;
+ state.scroll = null;
+ const html = document.documentElement, body = document.body;
+ html.style.overflow = s.htmlOverflow; html.style.paddingRight = s.htmlPadding;
+ body.style.position = s.bodyPosition; body.style.top = s.bodyTop;
+ body.style.left = s.bodyLeft; body.style.right = s.bodyRight; body.style.width = s.bodyWidth;
+ window.scrollTo(0, s.y);
+ }
/** Every chartable chip on the hero, in board order, deduplicated by metric. */
/** Every chartable chip on the hero, in board order, deduplicated by metric. */
function collectMetrics() {
function collectMetrics() {
@@ -109,6 +131,7 @@
@@ -109,6 +131,7 @@
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('keydown', onKey, true);
const ov = state.overlay;
const ov = state.overlay;
state.overlay = null;
state.overlay = null;
+ unlockPage();
ov.classList.add('is-closing');
ov.classList.add('is-closing');
setTimeout(() => ov.remove(), 160);
setTimeout(() => ov.remove(), 160);
try { state.lastFocus?.focus?.({ preventScroll: true }); } catch { /* */ }
try { state.lastFocus?.focus?.({ preventScroll: true }); } catch { /* */ }
@@ -172,6 +195,7 @@
@@ -172,6 +195,7 @@
</div>`;
</div>`;
document.body.appendChild(ov);
document.body.appendChild(ov);
state.overlay = ov;
state.overlay = ov;
+ lockPage();
ov.addEventListener('click', (e) => { if (e.target === ov) close(); });
ov.addEventListener('click', (e) => { if (e.target === ov) close(); });
ov.querySelector('.sn-close').addEventListener('click', close);
ov.querySelector('.sn-close').addEventListener('click', close);
ov.querySelector('.sn-prev').addEventListener('click', () => step(-1));
ov.querySelector('.sn-prev').addEventListener('click', () => step(-1));
@@ -189,6 +213,24 @@
@@ -189,6 +213,24 @@
sx = null;
sx = null;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);
}, { passive: true });
}, { passive: true });
+ // Pulling the sheet's header down closes it; the chart and table keep their own scroll.
+ const dragHead = ov.querySelector('.sn-head');
+ let drag = null;
+ dragHead.addEventListener('pointerdown', (e) => {
+ if (e.pointerType !== 'touch' || e.target.closest('button, a, input')) return;
+ drag = { id: e.pointerId, y: e.clientY };
+ try { dragHead.setPointerCapture(e.pointerId); } catch (_) { /* synthetic pointer or interrupted gesture */ }
+ });
+ dragHead.addEventListener('pointermove', (e) => {
+ if (drag && drag.id === e.pointerId) head.style.translate = `0 ${Math.max(0, e.clientY - drag.y)}px`;
+ });
+ const endDrag = (e) => {
+ if (!drag || drag.id !== e.pointerId) return;
+ const dy = e.clientY - drag.y; drag = null; head.style.translate = '';
+ if (dy > 90) close();
+ };
+ dragHead.addEventListener('pointerup', endDrag);
+ dragHead.addEventListener('pointercancel', endDrag);
const plot = ov.querySelector('.sn-plot');
const plot = ov.querySelector('.sn-plot');
// Only a chart that has points: an error answer has none, and redrawing it threw on every resize.
// Only a chart that has points: an error answer has none, and redrawing it threw on every resize.
state.ro = new ResizeObserver(() => { if (state.data?.points && !state.table) drawChart(); });
state.ro = new ResizeObserver(() => { if (state.data?.points && !state.table) drawChart(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('openvibe-shared/browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('broadcast-sheets-chrome: skipped (no Chrome)');
+ process.exit(0);
+}
+const source = fs.readFileSync(path.join(__dirname, '..', 'public/js/broadcast.js'), 'utf8');
+function extract(name) {
+ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(source);
+ assert.ok(m, `${name} exists`);
+ let i = source.indexOf('{', m.index + m[0].length), depth = 0;
+ for (; i < source.length; i++) {
+ if (source[i] === '{') depth++;
+ else if (source[i] === '}' && --depth === 0) return source.slice(m.index, i + 1);
+ }
+ throw new Error(`${name} is unbalanced`);
+}
+const script = `let _bcMobileChatOpen=false;const _bcSheets=new Set();const _bcSheetOpeners=new Map();let _bcPageBeforeSheet=null;let _bcSheetKeysBound=false;\n${[' _bcLockSheet', '_bcUnlockSheet', '_bcDragSheet', 'toggleBroadcastMobileChat', 'closeCameraSwitcher'].map((name) => extract(name.trim())).join('\n')}`;
+const page = `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><style>
+body{margin:0;background:#111827;color:white}main{height:1800px}#bc-chat-sidebar{position:fixed;bottom:0;left:0;right:0;height:400px;background:#202b42;transform:translateY(100%)}#bc-chat-sidebar.mobile-chat-open{transform:none}.chat-header{height:50px;touch-action:none}.chat-messages{height:280px;overflow:auto;overscroll-behavior:contain}#bc-camera-sheet{position:fixed;inset:0;background:#0008;align-items:end}.bc-camera-sheet-panel{height:350px;background:#27344a;width:100%}.bc-camera-sheet-header{height:55px;touch-action:none}
+</style><button id="bc-mobile-chat-toggle"><i></i>Chat</button><button id="bc-btn-switch-cam">Camera</button><main>Broadcast</main><div id="bc-chat-sidebar"><div class="chat-header">Chat <button onclick="toggleBroadcastMobileChat()">Close</button></div><div class="chat-messages" id="bc-chat-messages"><div style="height:1000px">Messages</div></div></div><span id="bc-mobile-chat-badge">2</span><div id="bc-camera-sheet" style="display:none"><div class="bc-camera-sheet-panel"><div class="bc-camera-sheet-header">Cameras <button onclick="closeCameraSwitcher()">Close</button></div></div></div><script src="/snippet.js"></script>`;
+const server = http.createServer((req, res) => {
+ if (req.url === '/snippet.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(script); }
+ res.writeHead(200, { 'content-type': 'text/html' }); res.end(page);
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-broadcast-sheet-'));
+ const browser = await h.launch({ tmpDir: tmp });
+ try {
+ const context = await browser.newContext(), p = await h.openPage(browser, context, { width: 390, height: 844 });
+ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
+ await p.evaluate('scrollTo(0,400);window.__before=scrollY;toggleBroadcastMobileChat()');
+ assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', 'chat locks the page');
+ assert.strictEqual(await p.evaluate('document.body.style.top'), await p.evaluate('`${-__before}px`'), 'chat retains page position');
+ await p.evaluate('document.querySelector("#bc-chat-messages").scrollTop=100');
+ assert.strictEqual(await p.evaluate('document.querySelector("#bc-chat-messages").scrollTop'), 100, 'chat scrolls');
+ await p.evaluate(`(() => {const head=document.querySelector('.chat-header');const fire=(type,y)=>head.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerType:'touch',pointerId:5,clientY:y}));fire('pointerdown',200);fire('pointermove',320);fire('pointerup',320)})()`);
+ assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'header swipe closes chat and unlocks');
+ assert.strictEqual(await p.evaluate('Math.round(scrollY)'), await p.evaluate('Math.round(__before)'), 'chat restores scroll');
+ await p.evaluate('document.querySelector("#bc-camera-sheet").style.display="flex";_bcLockSheet("camera",document.querySelector("#bc-btn-switch-cam"))');
+ await p.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
+ await p.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
+ assert.strictEqual(await p.evaluate('document.querySelector("#bc-camera-sheet").style.display'), 'none', 'Escape closes camera sheet');
+ assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'camera close unlocks');
+ await p.close();
+ console.log('broadcast-sheets-chrome: chat scroll and swipe, camera Escape and page lock ok');
+ } finally { await browser.close(); server.close(); fs.rmSync(tmp, { recursive: true, force: true }); }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -94,15 +94,19 @@ console.log('OK the layout at phone widths: stacked, scrollable, safe-area aware
@@ -94,15 +94,19 @@ console.log('OK the layout at phone widths: stacked, scrollable, safe-area aware
{
{
const els = {
const els = {
'bc-mobile-chat-toggle': { style: { display: 'none' }, querySelector: () => ({ className: '' }) },
'bc-mobile-chat-toggle': { style: { display: 'none' }, querySelector: () => ({ className: '' }) },
- 'bc-chat-sidebar': { classList: classList() },
+ 'bc-chat-sidebar': { classList: classList(), querySelector: () => ({}) },
'bc-chat-messages': { scrollTop: 0, scrollHeight: 900 },
'bc-chat-messages': { scrollTop: 0, scrollHeight: 900 },
'bc-mobile-chat-badge': { style: {}, textContent: '3' },
'bc-mobile-chat-badge': { style: {}, textContent: '3' },
};
};
+ const locks = [];
const fx = {
const fx = {
document: { getElementById: (id) => els[id] || null, body: { classList: classList() } },
document: { getElementById: (id) => els[id] || null, body: { classList: classList() } },
window: { innerWidth: 390 },
window: { innerWidth: 390 },
broadcastState: { streams: new Map() },
broadcastState: { streams: new Map() },
_bcMobileChatOpen: false,
_bcMobileChatOpen: false,
+ _bcLockSheet: (id) => locks.push('open:' + id),
+ _bcUnlockSheet: (id) => locks.push('close:' + id),
+ _bcDragSheet: () => {},
};
};
const f = run([extract(bc, 'updateBroadcastMobileChatFab'), extract(bc, 'toggleBroadcastMobileChat')].join('\n'), ['updateBroadcastMobileChatFab', 'toggleBroadcastMobileChat'], fx);
const f = run([extract(bc, 'updateBroadcastMobileChatFab'), extract(bc, 'toggleBroadcastMobileChat')].join('\n'), ['updateBroadcastMobileChatFab', 'toggleBroadcastMobileChat'], fx);
f.updateBroadcastMobileChatFab();
f.updateBroadcastMobileChatFab();
@@ -115,7 +119,10 @@ console.log('OK the layout at phone widths: stacked, scrollable, safe-area aware
@@ -115,7 +119,10 @@ console.log('OK the layout at phone widths: stacked, scrollable, safe-area aware
assert.strictEqual(els['bc-mobile-chat-toggle'].style.display, 'none', 'a desktop has the sidebar');
assert.strictEqual(els['bc-mobile-chat-toggle'].style.display, 'none', 'a desktop has the sidebar');
f.toggleBroadcastMobileChat();
f.toggleBroadcastMobileChat();
assert.ok(els['bc-chat-sidebar'].classList.contains('mobile-chat-open') && fx.document.body.classList.contains('mobile-chat-visible'));
assert.ok(els['bc-chat-sidebar'].classList.contains('mobile-chat-open') && fx.document.body.classList.contains('mobile-chat-visible'));
+ assert.deepStrictEqual(locks, ['open:chat'], 'phone chat locks the page');
assert.deepStrictEqual([els['bc-chat-messages'].scrollTop, els['bc-mobile-chat-badge'].textContent], [900, '0'], 'opens at the newest line, unread cleared');
assert.deepStrictEqual([els['bc-chat-messages'].scrollTop, els['bc-mobile-chat-badge'].textContent], [900, '0'], 'opens at the newest line, unread cleared');
+ f.toggleBroadcastMobileChat();
+ assert.deepStrictEqual(locks, ['open:chat', 'close:chat'], 'closing chat unlocks the page');
assert.match(fragment, /id="bc-mobile-chat-toggle" onclick="toggleBroadcastMobileChat\(\)"/);
assert.match(fragment, /id="bc-mobile-chat-toggle" onclick="toggleBroadcastMobileChat\(\)"/);
assert.match(bc, /window\.addEventListener\('resize'[\s\S]{0,200}updateBroadcastMobileChatFab\(\);/, 'rotation and resizing re-decide');
assert.match(bc, /window\.addEventListener\('resize'[\s\S]{0,200}updateBroadcastMobileChatFab\(\);/, 'rotation and resizing re-decide');
console.log('OK chat button: only live on a phone, opens the sheet');
console.log('OK chat button: only live on a phone, opens the sheet');
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('openvibe-shared/browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('stats-nerds-sheet-chrome: skipped (no Chrome)');
+ process.exit(0);
+}
+const root = path.join(__dirname, '..');
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
+:root{color-scheme:dark;--bg-card:#151923;--border:#343947;--text-primary:#f0f2f6;--text-secondary:#b5becd;--text-muted:#9aa5b8;--accent:#68a6ff;--bg-secondary:#222838}
+body{margin:0;background:#10141d;color:var(--text-primary);font:15px system-ui}button{cursor:pointer}#page{height:1800px;padding:20px;box-sizing:border-box}
+</style><link rel="stylesheet" href="/stats.css"><main id="page"><div id="hero-stats"><button class="hero-stat" data-metric="users" data-tip='{"label":"People","desc":"People on OpenVibe"}'><span class="hero-stat-num" data-n="1200">1,200</span></button></div></main>
+<script>window.api=async()=>({kind:'count',before:100,total:735,prev_total:650,points:Array.from({length:30},(_,i)=>({day:'2026-10-'+String(i+1).padStart(2,'0'),value:i+10}))});</script>
+<script src="/stats.js"></script><script>document.querySelector('.hero-stat').onclick=e=>openStatsNerds(e.currentTarget)</script>`;
+const server = http.createServer((req, res) => {
+ const f = req.url === '/stats.css' ? 'public/css/features/stats.css' : req.url === '/stats.js' ? 'public/js/stats-nerds.js' : null;
+ if (f) { res.writeHead(200, { 'content-type': f.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, f))); }
+ res.writeHead(200, { 'content-type': 'text/html' }); res.end(html);
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-stats-sheet-'));
+ const browser = await h.launch({ tmpDir: tmp });
+ try {
+ const context = await browser.newContext();
+ for (const width of [390, 1280]) {
+ const p = await h.openPage(browser, context, { width, height: 900 });
+ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
+ await p.evaluate('document.querySelector(".hero-stat").focus({preventScroll:true});scrollTo(0,400);window.__before=scrollY;document.querySelector(".hero-stat").click()');
+ await new Promise((ok) => setTimeout(ok, 100));
+ assert.strictEqual(await p.evaluate('document.body.style.position'), 'fixed', `${width}: page locked`);
+ assert.strictEqual(await p.evaluate('document.body.style.top'), await p.evaluate('`${-__before}px`'), `${width}: position kept`);
+ if (process.env.OV_SHEETS_SHOTS) {
+ fs.mkdirSync(process.env.OV_SHEETS_SHOTS, { recursive: true });
+ const shot = await p.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
+ fs.writeFileSync(path.join(process.env.OV_SHEETS_SHOTS, `live-stats-${width}.png`), Buffer.from(shot.data, 'base64'));
+ }
+ await p.evaluate('document.querySelector(".sn-dialog").appendChild(Object.assign(document.createElement("div"),{style:"height:1000px"}))');
+ await p.evaluate('document.querySelector(".sn-dialog").scrollTop=100');
+ assert.ok(await p.evaluate('document.querySelector(".sn-dialog").scrollTop') > 0, `${width}: sheet scrolls`);
+ await p.evaluate('document.querySelector(".sn-close").click()');
+ assert.strictEqual(await p.evaluate('Math.round(scrollY)'), await p.evaluate('Math.round(__before)'), `${width}: position restored`);
+ assert.strictEqual(await p.evaluate('document.activeElement.className'), 'hero-stat', `${width}: focus restored`);
+ if (width === 390) {
+ await p.evaluate('document.querySelector(".hero-stat").click()');
+ await p.evaluate(`(() => { const head=document.querySelector('.sn-head');
+ const fire=(type,y)=>head.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:6,pointerType:'touch',clientY:y}));
+ fire('pointerdown',200);fire('pointermove',320);fire('pointerup',320); })()`);
+ assert.strictEqual(await p.evaluate('document.body.style.position'), '', 'phone: header swipe closes and unlocks');
+ }
+ await p.close();
+ }
+ console.log('stats-nerds-sheet-chrome: phone and desktop lock, sheet scroll, position and focus ok');
+ } finally { await browser.close(); server.close(); fs.rmSync(tmp, { recursive: true, force: true }); }
+})().catch((e) => { console.error(e); process.exit(1); });