summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md14
-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
5 files changed, 332 insertions, 3 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index babbd86..8e2eafa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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">&#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.
"""