diff options
| -rw-r--r-- | CLAUDE.md | 14 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 23 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/harness/layout_probe.py | 140 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_layout_measured.py | 146 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_layout_responsive.py | 12 |
5 files changed, 332 insertions, 3 deletions
@@ -302,6 +302,19 @@ keypair bundle, or anything that looks like a user's public key. reproduced the defect in one run. Model the environment, never the code under test +- **A stylesheet does not tell you where anything lands.** The responsive tests + pinned numbers out of `style.css` and said in their own docstring that a + layout could not be measured because there was no browser in the suite. There + is one — Chrome, from the video work — and the difference is the transfers + panel: `width: 330px` was never the problem, the problem was that it is + anchored to a button which is not at the right edge of the screen, so it hung + 138 px off the left of a 320 px phone and hid the file names. No reading of + the rule would have shown that. `tests/harness/layout_probe.py` renders the + real stylesheet at a given width (in an iframe — a headless window will not go + below ~500 px) and returns rectangles; one browser measures every width, + because one apiece put three minutes on the suite. Assert on geometry, and + check the test fails with the fix removed + - **A hook cannot depend on one declared below it.** `const a = useCallback(fn, [b])` evaluates `[b]` where it is written, so a `b` further down the component is still in its temporal dead zone: `ReferenceError` on every render, before @@ -495,6 +508,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Seeking (browser) | `static/app.js` | `requestSeek` → node restarts ffmpeg with `-ss`; `reinitAt` clears the buffer and sets `timestampOffset`. `-copyts` does *not* preserve position — measured — so the offset comes from the client | | Seeking (node) | `webrtc_server.py` | `start` on `stream_req`; `-ss` **before** `-i` (index seek, not decode-and-discard), clamped away from the end, echoed in `stream_init` | | Resume position | `static/app.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch | +| Layout, measured | `tests/harness/layout_probe.py` | Renders `style.css` in Chrome at any width and returns bounding boxes. Use it for layout, not `test_layout_responsive.py`, which only pins CSS values | ## meshbay.org server (état cible) 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">☰</button> + <a class="nav-brand" href="#/">MeshBay</a> + </div> + <div class="nav-right"> + <div class="transfer-wrap"> + <button class="nav-notif transfer-btn">↓</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">↓</span> + <span class="transfer-name">S03E01. Salt and Sea, Fire and Blood.mp4</span> + <button class="transfer-cancel">✕</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="#/">🔔</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. """ |