openvibers/openvibe.live Commits

Keep Live stats sheet scroll inside the sheet (#721)

OP OpenVibers committed 4 days ago · 069491142c · 7 files, +235 −5

* Keep Live stats sheet scroll inside the sheet

* Keep broadcast chat and camera sheets above a still page

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 553408ce..64375658 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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;
ddiff --git a/public/css/features/stats.css b/public/css/features/stats.css
iindex c5f7480d..2aff12a2 100644
---- a/public/css/features/stats.css
++++ b/public/css/features/stats.css
@@@ -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; }
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 970ef8b9..a97dcd9a 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -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 {
ddiff --git a/public/js/stats-nerds.js b/public/js/stats-nerds.js
iindex cb393978..9c07d986 100644
---- a/public/js/stats-nerds.js
++++ b/public/js/stats-nerds.js
@@@ -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(); });
ddiff --git a/test/broadcast-sheets-chrome.test.js b/test/broadcast-sheets-chrome.test.js
nnew file mode 100644
iindex 00000000..9a68de77
---- /dev/null
++++ b/test/broadcast-sheets-chrome.test.js
@@@ -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); });
ddiff --git a/test/mobile-broadcast.test.js b/test/mobile-broadcast.test.js
iindex 6e496c9f..4ddffafa 100644
---- a/test/mobile-broadcast.test.js
++++ b/test/mobile-broadcast.test.js
@@@ -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 aware
assert.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');
ddiff --git a/test/stats-nerds-sheet-chrome.test.js b/test/stats-nerds-sheet-chrome.test.js
nnew file mode 100644
iindex 00000000..9d2b4315
---- /dev/null
++++ b/test/stats-nerds-sheet-chrome.test.js
@@@ -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); });