diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-15 02:18:38 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-15 02:21:01 +0200 |
| commit | acb0d666540dacb092f596dada25822d1d0466f0 (patch) | |
| tree | 7dc61234255c4f18a1bbc24754bcad53385c55d5 /packages/meshbay-hub/tests/harness/sidebar_scroll_probe.py | |
| parent | 73ad8e4eb566fe682107fa7e50ef624591199e99 (diff) | |
| download | meshbay-acb0d666540dacb092f596dada25822d1d0466f0.tar.gz | |
fix(ui): sidebar stays above a phone's address bar and does not scroll the page
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
Diffstat (limited to 'packages/meshbay-hub/tests/harness/sidebar_scroll_probe.py')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/sidebar_scroll_probe.py | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/sidebar_scroll_probe.py b/packages/meshbay-hub/tests/harness/sidebar_scroll_probe.py new file mode 100644 index 0000000..0324367 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/sidebar_scroll_probe.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Measure the sidebar holding many groups, at a phone and a desktop size. + +Renders the real style.css with the sidebar as `Sidebar()` draws it — twelve +groups, the drawer open on the phone — beside a page long enough to scroll, and +reports what a person would meet: whether the list scrolls, whether its last +group and the legal link can be reached, and whether reaching the end hands the +scroll on to the page. + +What this cannot show is the address bar of a real phone: an iframe has no +dynamic toolbar, so `100vh` and `100dvh` measure the same here. + + sidebar_scroll_probe.py +""" +import http.server +import json +import shutil +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" +PORT = 8744 +SIZES = [(390, 740), (1280, 800)] + +GROUPS = "".join( + f'<a class="sidebar-item sidebar-group" href="#"><span class="si-head">' + f'<span class="presence presence-online"></span>' + f'<span class="sidebar-item-name">A Shared Library Number {i}</span></span>' + f'<span class="sidebar-owner">@owner_account</span></a>' + for i in range(1, 13)) + +FRAGMENT = f""" +<nav class="nav"><div class="nav-left"><button class="nav-hamburger">=</button> +<a class="nav-brand" href="#">MeshBay</a></div><div class="nav-right"></div></nav> +<div class="layout"> + <aside class="sidebar open"> + <div class="sidebar-section"><div class="sidebar-heading">Discover</div> + <a class="sidebar-item" href="#">Search</a></div> + <div class="sidebar-section"><div class="sidebar-heading">Node</div> + <a class="sidebar-item" href="#">Node</a><a class="sidebar-item" href="#">Create</a></div> + <div class="sidebar-section"><div class="sidebar-heading">My groups</div>{GROUPS}</div> + <a class="sidebar-legal" href="#">Legal</a> + </aside> + <main class="main"><div style="height:3000px">a long page</div></main> +</div>""" + +PAGE = """<!doctype html><html><head><meta charset=utf-8></head><body style="margin:0"> +<div id="frames"></div><script> +const SIZES = %(sizes)s, FRAG = %(fragment)s; +for (const [w, h] of SIZES) { + const f = document.createElement('iframe'); + f.id = 'f' + w; f.style.cssText = `width:${w}px;height:${h}px;border:0;display:block`; + document.getElementById('frames').appendChild(f); + const d = f.contentDocument; d.open(); + d.write(`<!doctype html><html><head><meta charset=utf-8> +<link rel="stylesheet" href="/style.css"></head><body>${FRAG}</body></html>`); d.close(); +} +setTimeout(() => { + const out = {}; + for (const [w, h] of SIZES) { + const win = document.getElementById('f' + w).contentWindow, doc = win.document; + const sb = doc.querySelector('.sidebar'), cs = win.getComputedStyle(sb); + const bottom = (el) => Math.round(el.getBoundingClientRect().bottom); + const r = {viewportHeight: win.innerHeight, position: cs.position, + sidebarBottom: bottom(sb), clientHeight: sb.clientHeight, + scrollHeight: sb.scrollHeight, overflowY: cs.overflowY, + overscrollBehaviorY: cs.overscrollBehaviorY}; + sb.scrollTop = sb.scrollHeight; + const groups = doc.querySelectorAll('.sidebar-group'); + r.lastGroupBottom = bottom(groups[groups.length - 1]); + r.legalBottom = bottom(doc.querySelector('.sidebar-legal')); + out[w] = r; + } + fetch('/log', {method: 'POST', body: JSON.stringify(out)}); +}, 700); +</script></body></html>""" + +RECORDS = [] + + +def main() -> int: + class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_POST(self): + RECORDS.append(json.loads( + self.rfile.read(int(self.headers["Content-Length"])).decode())) + self.send_response(204) + self.end_headers() + + def do_GET(self): + if self.path == "/": + body = (PAGE % {"sizes": json.dumps(SIZES), + "fragment": json.dumps(FRAGMENT)}).encode() + ctype = "text/html; charset=utf-8" + elif self.path == "/style.css": + body = (STATIC / "style.css").read_bytes() + ctype = "text/css" + else: + self.send_response(404) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + class S(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + srv = S(("127.0.0.1", PORT), H) + threading.Thread(target=srv.serve_forever, daemon=True).start() + profile = tempfile.mkdtemp(prefix="chrome-sidebar-") + chrome = subprocess.Popen([ + "google-chrome", "--headless=new", "--no-sandbox", + "--window-size=1400,1700", "--user-data-dir=" + profile, + f"http://127.0.0.1:{PORT}/", + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.time() + 45 + while time.time() < deadline and not RECORDS: + time.sleep(0.2) + chrome.terminate() + try: + chrome.wait(timeout=10) + except subprocess.TimeoutExpired: + chrome.kill() + chrome.wait() + shutil.rmtree(profile, ignore_errors=True) + srv.shutdown() + if not RECORDS: + print(json.dumps({"error": "no measurement"})) + return 1 + print(json.dumps(RECORDS[0])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |