summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css7
-rw-r--r--packages/meshbay-hub/tests/harness/sidebar_scroll_probe.py147
-rw-r--r--packages/meshbay-hub/tests/test_sidebar_scroll_measured.py68
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")