aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-17 09:28:49 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-17 09:28:49 +0200
commitaf30a10b83366416c25eaacec0b4df77526d0924 (patch)
tree1395bf4d9000b262cebbf7d66fcb6c9bf22975eb /packages
parent42047dac4041e72e09499e3adf145f1c0f83b284 (diff)
downloadmeshbay-af30a10b83366416c25eaacec0b4df77526d0924.tar.gz
fix(hub): the transfers panel hung off the side of a phone
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.
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css23
-rw-r--r--packages/meshbay-hub/tests/harness/layout_probe.py140
-rw-r--r--packages/meshbay-hub/tests/test_layout_measured.py146
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py12
4 files changed, 318 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 5987d1c..1ba0ef0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1522,6 +1522,29 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.group-header { margin-bottom: 10px; gap: 8px; }
.group-tabs { margin-bottom: 10px; }
.chat-messages { padding: 12px; }
+
+ /* The transfers panel stops hanging off its button.
+ It is 330 px wide and anchored to the button's right edge, but that button
+ is not at the right edge of the screen — the bell and the user menu come
+ after it. So the panel extended past the left of the viewport: measured at
+ 138 px lost on a 320 px screen, 98 px at 360. What is cut is the left-hand
+ side, which is where the file names are, so what remained was a strip of
+ progress bars belonging to nothing.
+ Anchoring to the viewport instead of to the button is the only thing that
+ helps: capping the width does not, since the overflow comes from where the
+ right edge is pinned. */
+ .transfer-panel {
+ position: fixed;
+ top: 52px;
+ right: 8px;
+ /* Full width on a phone, and no wider than the desktop panel on a tablet,
+ where stretching it across 768 px would be silly. Both edges are pinned
+ to the viewport, so the width follows from the screen rather than from
+ where the button happens to sit. */
+ left: max(8px, calc(100vw - 428px));
+ width: auto;
+ max-height: calc(100vh - 68px);
+ }
}
/* ── Notification bell ───────────────────────────────────────────────────── */
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 <widths,comma,separated> <html-fragment-file> <selector> [...]
+
+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 = """<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0">
+<!-- One iframe per width. A headless window will not go below about 500 px,
+ and an iframe establishes its own viewport, so media queries inside it see
+ the phone width we mean. -->
+<div id="frames"></div>
+<script>
+const WIDTHS = %(widths)s, SELECTORS = %(selectors)s;
+const FRAG = %(fragment)s;
+const host = document.getElementById('frames');
+for (const w of WIDTHS) {
+ const f = document.createElement('iframe');
+ f.id = 'f' + w;
+ f.style.cssText = `width:${w}px;height:740px;border:0;display:block`;
+ host.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 of WIDTHS) {
+ const win = document.getElementById('f' + w).contentWindow;
+ const r = {viewport: {w: win.innerWidth, h: win.innerHeight},
+ docScrollW: win.document.documentElement.scrollWidth, boxes: {}};
+ for (const sel of SELECTORS) {
+ const el = win.document.querySelector(sel);
+ if (!el) { r.boxes[sel] = null; continue; }
+ const b = el.getBoundingClientRect();
+ r.boxes[sel] = {
+ left: Math.round(b.left), right: Math.round(b.right),
+ top: Math.round(b.top), width: Math.round(b.width),
+ height: Math.round(b.height),
+ offLeft: Math.round(Math.max(0, -b.left)),
+ offRight: Math.round(Math.max(0, b.right - win.innerWidth)),
+ };
+ }
+ out[w] = r;
+ }
+ fetch('/log', {method: 'POST', body: JSON.stringify(out)});
+}, 500);
+</script></body></html>"""
+
+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())
diff --git a/packages/meshbay-hub/tests/test_layout_measured.py b/packages/meshbay-hub/tests/test_layout_measured.py
new file mode 100644
index 0000000..91f1ed0
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_layout_measured.py
@@ -0,0 +1,146 @@
+"""
+Layouts measured in a browser instead of read out of the stylesheet.
+
+`test_layout_responsive.py` says, in its own docstring, that a layout cannot be
+measured here because there is no browser in the suite. There is one now —
+Chrome is what the video work has been verified against — and the difference
+matters: `width: 330px` in a rule tells you nothing about whether the thing
+lands on the screen. That depends on where its anchor sits, which depends on
+everything to its right.
+
+The transfers panel is the case that proved it. 330 px wide, anchored to the
+right edge of its button — but that button is not at the right edge of the
+screen, the bell and the user menu come after it. Measured before the fix:
+
+ 320 px viewport -> panel at -138..192, 138 px off the left
+ 360 px -> -98..232
+ 412 px -> -46..284
+
+What is cut off is the left-hand side, which is where the file names are, so
+what was left on screen was a strip of progress bars belonging to nothing —
+reported as "on mobile you only see the right-hand side, without the content".
+
+Every assertion here is a rectangle, not a declaration.
+"""
+
+import json
+import shutil
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "layout_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
+ reason="Chrome or the SPA stylesheet is not available")
+
+# The nav as `Nav()` renders it: the transfers widget is not last — the bell and
+# the user menu follow it, which is the whole reason the panel hangs off.
+NAV = textwrap.dedent("""
+ <nav class="nav">
+ <div class="nav-left">
+ <button class="nav-hamburger">&#9776;</button>
+ <a class="nav-brand" href="#/">MeshBay</a>
+ </div>
+ <div class="nav-right">
+ <div class="transfer-wrap">
+ <button class="nav-notif transfer-btn">&#8595;</button>
+ <div class="transfer-panel">
+ <div class="transfer-head">Transfers<button class="btn-secondary">Clear</button></div>
+ <div class="transfer-item">
+ <div class="transfer-line">
+ <span class="transfer-kind">&#8595;</span>
+ <span class="transfer-name">S03E01. Salt and Sea, Fire and Blood.mp4</span>
+ <button class="transfer-cancel">&#10005;</button>
+ </div>
+ <div class="dl-progress"><div class="dl-fill" style="width:42%"></div></div>
+ <div class="transfer-meta"><span>210 MB / 493 MB</span><span>3.1 MB/s</span></div>
+ </div>
+ </div>
+ </div>
+ <a class="nav-notif" href="#/">&#128276;</a>
+ <div class="user-menu"><button class="nav-btn">someone</button></div>
+ </div>
+ </nav>
+""")
+
+
+WIDTHS = [320, 360, 412, 768, 1024]
+SELECTORS = [".transfer-panel", ".transfer-name"]
+
+
+@pytest.fixture(scope="module")
+def measured(tmp_path_factory):
+ """One browser for every width, because launching one apiece cost the
+ suite three minutes."""
+ fragment = tmp_path_factory.mktemp("layout") / "fragment.html"
+ fragment.write_text(NAV)
+ proc = subprocess.run(
+ ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS),
+ str(fragment), *SELECTORS],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, f"no measurement: {out}"
+ for w in WIDTHS:
+ assert out[str(w)]["viewport"]["w"] == w, (
+ f"asked for {w} px and measured at {out[str(w)]['viewport']['w']}")
+ return out
+
+
+def _box(measured, width: int, selector: str) -> dict:
+ return measured[str(width)]["boxes"][selector]
+
+
+@pytest.mark.parametrize("width", [320, 360, 412])
+def test_the_transfers_panel_fits_a_phone(measured, width):
+ """The reported defect, as a rectangle."""
+ box = _box(measured, width, ".transfer-panel")
+ assert box is not None, "the panel did not render"
+ assert box["offLeft"] == 0, (
+ f"{box['offLeft']} px of the panel is off the left of a {width} px "
+ "screen — and the left is where the file names are")
+ assert box["offRight"] == 0, (
+ f"{box['offRight']} px of the panel is off the right of a {width} px screen")
+
+
+@pytest.mark.parametrize("width", [320, 360])
+def test_the_file_name_gets_room_to_be_read(measured, width):
+ """Fitting on screen is not the same as being legible.
+
+ A panel could satisfy the test above by being narrow enough to show
+ nothing. The name is the one part a viewer needs.
+ """
+ box = _box(measured, width, ".transfer-name")
+ assert box is not None and box["width"] >= 180, (
+ f"the file name has {box['width'] if box else 0} px on a {width} px "
+ "screen, which is not enough to tell two downloads apart")
+
+
+def test_the_desktop_panel_is_untouched(measured):
+ """The fix is a media query, and it must stay inside it."""
+ box = _box(measured, 1024, ".transfer-panel")
+ assert box["width"] == 330, (
+ f"the desktop panel is now {box['width']} px — the mobile rule has "
+ "escaped its breakpoint")
+ assert box["offLeft"] == 0 and box["offRight"] == 0
+
+
+def test_it_does_not_stretch_across_a_tablet(measured):
+ """Pinned to both edges, a panel would be 750 px wide at 768."""
+ box = _box(measured, 768, ".transfer-panel")
+ assert box["width"] <= 440, (
+ f"{box['width']} px of panel on a tablet, which is a list of two "
+ "filenames stretched over most of the screen")
+
+
+@pytest.mark.parametrize("width", [320, 360, 412])
+def test_the_page_does_not_scroll_sideways(measured, width):
+ """The other half of "it fits": nothing pushed the document wider."""
+ r = measured[str(width)]
+ assert r["docScrollW"] <= r["viewport"]["w"], (
+ f"the document scrolls to {r['docScrollW']} px on a {width} px screen")
diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py
index 741e728..0e2444c 100644
--- a/packages/meshbay-hub/tests/test_layout_responsive.py
+++ b/packages/meshbay-hub/tests/test_layout_responsive.py
@@ -12,9 +12,15 @@ for 440 px:
`margin-left: auto` pushed the excess off the right-hand side rather than the
left — which is exactly how it was seen.
-These assertions read the stylesheet. That is weak evidence and it is what is
-available: there is no browser in this suite, so a layout cannot be measured
-here, only its inputs pinned. What they buy is that the four rules holding the
+These assertions read the stylesheet, which pins the inputs to a layout without
+measuring the layout. That was all there was when they were written; it is no
+longer. `test_layout_measured.py` renders the real stylesheet in Chrome at a
+phone width and asserts on rectangles, which is what caught the transfers panel
+hanging 138 px off the left of a 320 px screen — a defect no reading of
+`width: 330px` would have revealed, since it came from where the panel was
+anchored rather than from how wide it was.
+
+Prefer that for anything new. What these buy is that the four rules holding the
toolbar together cannot be removed without something saying so.
"""