From af30a10b83366416c25eaacec0b4df77526d0924 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 17 Aug 2026 09:28:49 +0200 Subject: fix(hub): the transfers panel hung off the side of a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: on mobile you see only the right-hand edge of the panel, without the content. Measured, before anything was changed: 320 px viewport -> panel at -138..192, 138 px off the left 360 px -> -98..232 412 px -> -46..284 The panel is 330 px wide and anchored to the right edge of its button — but that button is not at the right edge of the screen, since the bell and the user menu come after it. What falls off is the left-hand side, which is where the file names are, so what stayed on screen was a strip of progress bars belonging to nothing. Narrowing it would not have helped: the overflow comes from where the right edge is pinned, not from the width. Below the existing 768 px breakpoint the panel is anchored to the viewport instead, full width on a phone and capped at 420 px on a tablet, where stretching two filenames across 750 px would be silly. Desktop keeps its 330 px against the button. The interesting part is how it was found. The responsive tests read numbers out of the stylesheet and said, in their own docstring, that a layout could not be measured because the suite had no browser. It has one now — Chrome, from the video work — so layout_probe.py renders the real stylesheet at a given width and returns rectangles. `width: 330px` was never the thing worth asserting on. An iframe carries the viewport, because a headless window will not go below about 500 px, and one browser measures every width: launching one per test put three minutes on the suite against twenty-six seconds for all of them. Checked that the new tests fail with the rule removed — three of them do — and that they pass with it back. --- packages/meshbay-hub/tests/harness/layout_probe.py | 140 +++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/meshbay-hub/tests/harness/layout_probe.py (limited to 'packages/meshbay-hub/tests/harness') diff --git a/packages/meshbay-hub/tests/harness/layout_probe.py b/packages/meshbay-hub/tests/harness/layout_probe.py new file mode 100644 index 0000000..530b3f0 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/layout_probe.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Measure a piece of the SPA at a phone width, in a real browser. + +The responsive tests up to now pinned numbers out of the stylesheet, with a +docstring admitting that a layout cannot be measured because there is no +browser in the suite. There is one: Chrome is what the video work has been +verified against. Reading `width: 330px` out of a rule says nothing about +whether the thing lands on the screen — that depends on where its anchor sits, +which depends on everything to its right. + +Renders the real style.css with a fragment of markup, at a given viewport, and +reports the bounding box of each selector asked for. + + layout_probe.py [...] + +One browser for all the widths asked for: an iframe apiece, measured in a +single pass. Launching Chrome per width put three minutes on the test suite. +""" +import http.server +import json +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 = 8734 + +PAGE = """ + + +
+""" + +RECORDS = [] + + +def main() -> int: + widths = [int(w) for w in sys.argv[1].split(",")] + fragment = Path(sys.argv[2]).read_text() + selectors = sys.argv[3:] + + 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 % {"fragment": json.dumps(fragment), + "widths": json.dumps(widths), + "selectors": json.dumps(selectors)}).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() + chrome = subprocess.Popen([ + "google-chrome", "--headless=new", "--no-sandbox", + "--window-size=1000,900", + "--user-data-dir=" + tempfile.mkdtemp(prefix="chrome-layout-"), + 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() + 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()) -- cgit v1.2.3