diff options
Diffstat (limited to 'packages')
3 files changed, 222 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index a2ef80e..bae1a82 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -403,6 +403,12 @@ a:hover { text-decoration: underline; } flex-direction: column; width: 240px; height: calc(100vh - var(--nav-h) - var(--music-bar-h) - var(--index-dock-h)); + /* The visible viewport where the browser has the unit: on a phone or tablet + `100vh` is the height with the address bar hidden, which leaves the foot of + the list under the bar while it shows. The line above is the fallback. */ + height: calc(100dvh - var(--nav-h) - var(--music-bar-h) - var(--index-dock-h)); + /* Reaching either end of the list must not start scrolling the page behind. */ + overscroll-behavior: contain; background: var(--sidebar-bg); border-right: 1px solid var(--border); padding: 16px 0 0; @@ -2515,6 +2521,7 @@ a.transfer-name { left: -240px; top: 52px; height: calc(100vh - 52px - var(--music-bar-h) - var(--index-dock-h)); + height: calc(100dvh - 52px - var(--music-bar-h) - var(--index-dock-h)); z-index: 50; transition: left 0.2s; box-shadow: none; 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()) diff --git a/packages/meshbay-hub/tests/test_sidebar_scroll_measured.py b/packages/meshbay-hub/tests/test_sidebar_scroll_measured.py new file mode 100644 index 0000000..d2adaf1 --- /dev/null +++ b/packages/meshbay-hub/tests/test_sidebar_scroll_measured.py @@ -0,0 +1,68 @@ +""" +A sidebar with many groups scrolls on its own, on a phone and on a desktop. + +Measured, not read: twelve groups beside a page long enough to scroll, in +Chrome, at 390 and 1280 px. The list must scroll, its last group and the legal +link must be reachable, and reaching an end must not hand the scroll on to the +page behind — which it did, measured before the fix (`overscroll-behavior: +auto`). + +What no headless measurement shows is a phone's address bar: an iframe has no +dynamic toolbar, so `100vh` and `100dvh` agree here. That half is held by +reading the stylesheet, the weaker evidence, and is the known behaviour of +mobile browsers: `100vh` is the height with the bar hidden. +""" + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +HARNESS = Path(__file__).parent / "harness" / "sidebar_scroll_probe.py" +STYLE = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" / "style.css" + + +@pytest.fixture(scope="module") +def measured(): + if shutil.which("google-chrome") is None: + pytest.skip("Chrome is not available") + proc = subprocess.run([sys.executable, str(HARNESS)], + capture_output=True, text=True, timeout=90) + data = json.loads(proc.stdout.strip().splitlines()[-1]) + assert "error" not in data, f"probe failed: {proc.stdout}{proc.stderr}" + return data + + +@pytest.mark.parametrize("width", ["390", "1280"]) +def test_the_list_scrolls_inside_the_sidebar(measured, width): + m = measured[width] + assert m["overflowY"] == "auto" + assert m["scrollHeight"] > m["clientHeight"], "twelve groups should overflow" + assert m["sidebarBottom"] <= m["viewportHeight"], "the sidebar runs off the window" + + +@pytest.mark.parametrize("width", ["390", "1280"]) +def test_the_end_of_the_list_can_be_reached(measured, width): + m = measured[width] + assert m["lastGroupBottom"] <= m["sidebarBottom"] + assert m["legalBottom"] <= m["sidebarBottom"] + 1 + + +@pytest.mark.parametrize("width", ["390", "1280"]) +def test_reaching_an_end_does_not_scroll_the_page(measured, width): + assert measured[width]["overscrollBehaviorY"] == "contain" + + +def test_the_height_follows_the_visible_viewport_with_a_fallback(): + css = STYLE.read_text(encoding="utf-8") + rules = [m.group(1) for m in re.finditer(r"\n\s*\.sidebar \{(.*?)\}", css, re.S)] + assert len(rules) == 2, "expected the desktop and the phone .sidebar rules" + for body in rules: + heights = re.findall(r"height:\s*calc\((100d?vh)", body) + assert heights == ["100vh", "100dvh"], ( + f"a .sidebar rule sizes itself with {heights}: 100dvh is what keeps the " + "foot of the list above a phone's address bar, after a 100vh fallback") |