aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_layout_responsive.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:29:11 +0200
commit84e778d5cdd1bb5cded8a7c0238797c17d48666c (patch)
treed26f1d20735ba2db81da812371c92d257179882c /packages/meshbay-hub/tests/test_layout_responsive.py
parent188a76f52d2f30609147b1beee7754f2cbd1e778 (diff)
downloadmeshbay-84e778d5cdd1bb5cded8a7c0238797c17d48666c.tar.gz
feat(hub): chat, presence, a Profile page, and downloads that do not freeze
Chat opens on the newest hundred messages, loads fifty older on demand with the reading position anchored — the distance from the *bottom*, since everything above the viewport just grew — and follows new messages only when the reader was already at the end. Day separators, sender grouping, an unread marker, and a jump-to-latest pill. Messages are keyed by id: index keys plus prepending makes Preact reuse the wrong bubbles. A presence dot per group in the sidebar, three states, each backed by something: the hub's registry, or a connection this browser made or failed to make. Never colour alone — red and green are the pair colour-blind readers cannot separate — so each dot carries a title and an aria-label. Profile is split out of Settings: identity, node link, pinned node identities and account deletion. Mixing them put an irreversible button two scrolls under a theme picker. The create-group page loses its centred 520 px card, which left 190 px of margin either side, and its two button panels become a radio group — a button conveys no chosen state to a screen reader, and side by side they read as two independent actions rather than one either/or. The Files toolbar shows its actions as icon buttons the moment Select is on, disabled when they do not apply rather than appearing and vanishing. On a phone the right-hand group could not wrap and ran 130 px off the screen. Streamed downloads no longer freeze after one chunk. `registration.active` says a worker exists, not that this page is controlled by it — and an uncontrolled page's requests never reach its fetch handler, so the worker took the stream and was never asked for it, leaving `writer.write()` waiting on backpressure that would never lift. The page now requires control and the worker confirms it actually served the request before the sink is trusted. Fixed on the way: `setActionsOpen` outlived the state it belonged to and threw on every Files action; the chat scrollbar stopped short of the bottom; the owner's row sat lower than the rest; About showed a version hardcoded two releases ago. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_layout_responsive.py')
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py159
1 files changed, 159 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py
new file mode 100644
index 0000000..741e728
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_layout_responsive.py
@@ -0,0 +1,159 @@
+"""
+The rules that keep the Files toolbar inside a phone screen.
+
+Reported from a real handset: the toolbar ran off the right edge, the download
+button worst of all. The cause was arithmetic rather than subtle. At a 360 px
+viewport the toolbar has 310 px of usable width, and its right-hand group asked
+for 440 px:
+
+ filter 172 + Select 90 + five 30 px actions 166 + gaps 12 = 440
+
+`.toolbar-group` had no `flex-wrap`, so that group could not break, and
+`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
+toolbar together cannot be removed without something saying so.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+CSS = STATIC / "style.css"
+
+pytestmark = pytest.mark.skipif(
+ not CSS.exists(), reason="the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def css():
+ return CSS.read_text()
+
+
+def _rule(css: str, selector: str) -> str:
+ """The body of the first rule whose selector list starts with `selector`."""
+ m = re.search(r"^" + re.escape(selector) + r"[^{]*\{([^}]*)\}", css, re.M)
+ assert m, f"no rule found for {selector}"
+ return m.group(1)
+
+
+@pytest.fixture(scope="module")
+def mobile(css):
+ """The body of the max-width: 768px block."""
+ i = css.index("@media (max-width: 768px)")
+ depth, j = 0, css.index("{", i)
+ for k in range(j, len(css)):
+ if css[k] == "{":
+ depth += 1
+ elif css[k] == "}":
+ depth -= 1
+ if depth == 0:
+ return css[j:k]
+ pytest.fail("the mobile media query is not closed")
+
+
+def test_a_toolbar_group_can_break(css):
+ """Without this the right-hand group is a single unbreakable 440 px row."""
+ assert "flex-wrap: wrap" in _rule(css, ".toolbar-group")
+
+
+def test_the_filter_can_shrink(css):
+ """A fixed 150 px input keeps its width and pushes everything after it out."""
+ assert "min-width: 0" in _rule(css, ".tb-search")
+ assert "min-width: 0" in _rule(css, ".tb-search input")
+
+
+def test_the_action_buttons_wrap(css):
+ assert "flex-wrap: wrap" in _rule(css, ".tb-actions")
+
+
+def test_breadcrumbs_wrap(css):
+ """A deep path is the other way this row grows without limit."""
+ assert "flex-wrap: wrap" in _rule(css, ".breadcrumbs")
+
+
+def test_the_right_hand_group_stops_being_pushed_right_on_a_phone(mobile):
+ """`margin-left: auto` is what sent the overflow off-screen to the right."""
+ assert "margin-left: 0" in mobile
+ assert ".toolbar-group.right" in mobile
+
+
+def test_the_groups_take_a_line_each_on_a_phone(mobile):
+ assert "width: 100%" in mobile
+
+
+def test_the_toolbar_still_fits_a_360px_screen(css):
+ """The measurements the rules above are chosen against.
+
+ Recomputed from the stylesheet rather than restated, so a change to the
+ button height or the toolbar padding is caught here instead of on a phone.
+ """
+ icon_btn = _rule(css, ".tb-icon-btn")
+ size = int(re.search(r"width:\s*(\d+)px", icon_btn).group(1))
+ gap = int(re.search(r"gap:\s*(\d+)px", _rule(css, ".tb-actions")).group(1))
+
+ usable = 360 - 2 * 16 - 2 * 8 - 2 # viewport − .main − .file-toolbar − borders
+ actions = 5 * size + 4 * gap # play, view, download, zip, delete
+ assert actions <= usable, (
+ f"five actions need {actions}px and the toolbar offers {usable}px on a "
+ "360px screen — they no longer fit on their own line")
+
+
+# ── The chat panel's height ───────────────────────────────────────────────────
+
+APP = STATIC / "app.js"
+
+
+@pytest.fixture(scope="module")
+def app():
+ return APP.read_text()
+
+
+def test_the_chat_panel_is_measured_not_guessed(app):
+ """`calc(100vh - 220px)` was wrong twice over on a phone.
+
+ `100vh` is the viewport with the URL bar *hidden*, so with it showing the
+ panel is already taller than the screen. And 220px is a guess at the group
+ header, which carries a title, a description of any length, an edit link,
+ a delete button and the tabs. Between them the composer ended up below the
+ fold and the whole page scrolled to reach it.
+ """
+ assert "el.getBoundingClientRect().top + window.scrollY" in app, (
+ "the height must come from where the panel actually sits")
+ assert "window.visualViewport?.height || window.innerHeight" in app, (
+ "innerHeight alone ignores the on-screen keyboard on Android")
+
+
+def test_the_measurement_survives_a_scrolled_page(app):
+ """Document-relative, so the answer does not depend on the scroll offset."""
+ fit = app[app.index("const fit = () => {"):]
+ fit = fit[:fit.index("};")]
+ assert "window.scrollY" in fit
+
+
+def test_the_panel_refits_when_the_viewport_changes(app):
+ for event in ("resize", "orientationchange"):
+ assert f"addEventListener('{event}', fit)" in app
+ assert "visualViewport?.addEventListener('resize', fit)" in app
+ assert "removeEventListener('resize', fit)" in app, "the listener must be released"
+
+
+def test_the_css_floor_does_not_fight_the_measurement(css, app):
+ """A min-height above the computed value would put the scrollbar back."""
+ panel = _rule(css, ".chat-panel")
+ css_floor = int(re.search(r"min-height:\s*(\d+)px", panel).group(1))
+ js_floor = int(re.search(r"const CHAT_MIN_HEIGHT = (\d+)", app).group(1))
+ assert css_floor == js_floor, (
+ f"CSS floor {css_floor}px and JS floor {js_floor}px disagree — the "
+ "larger one silently wins and the page scrolls again")
+
+
+def test_the_fallback_height_uses_dvh(css):
+ """The value before the measurement runs, and if it never does."""
+ panel = _rule(css, ".chat-panel")
+ assert "dvh" in panel, "vh is the URL-bar-hidden viewport and overshoots"