From 84e778d5cdd1bb5cded8a7c0238797c17d48666c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 16 Aug 2026 15:29:11 +0200 Subject: feat(hub): chat, presence, a Profile page, and downloads that do not freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../meshbay-hub/tests/test_layout_responsive.py | 159 +++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_layout_responsive.py (limited to 'packages/meshbay-hub/tests/test_layout_responsive.py') 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" -- cgit v1.2.3