aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-01 23:36:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-01 23:36:17 +0200
commitd6713a4c7b3f94a3b63e0c0f78e7939fa7eae4e8 (patch)
treef0c00979232c1fd4c5bad0ddd726fbbde15eff1f /packages/meshbay-hub
parent799d87999c8324564dce5159191532e008dd93d2 (diff)
parent32855a95e11032302f8d24036f6f2dd44b829368 (diff)
downloadmeshbay-d6713a4c7b3f94a3b63e0c0f78e7939fa7eae4e8.tar.gz
Merge branch 'fix/chat-scroll-up'
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js99
-rw-r--r--packages/meshbay-hub/tests/harness/chat_scroll_probe.py222
-rw-r--r--packages/meshbay-hub/tests/test_chat_scroll_up.py102
-rw-r--r--packages/meshbay-hub/tests/test_layout_responsive.py15
4 files changed, 416 insertions, 22 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 1850c74..3bc110f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -216,6 +216,12 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
// them but before the browser paints.
const anchorRef = useRef(null);
const atBottomRef = useRef(true);
+ // Landing on the newest message is an *arrival* behaviour: it belongs to
+ // opening the group and to coming back to the Chat tab, and it has to survive
+ // the thumbnails and link-preview cards that keep growing the list for a
+ // second or two afterwards. It ends the moment the reader asks to move, and
+ // after that nothing may move the view for them.
+ const arrivingRef = useRef(true);
useEffect(() => {
if (status !== 'connected') return;
@@ -230,7 +236,13 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
setMessages(msgs);
requestAnimationFrame(() => {
const l = listRef.current;
- if (l) { l.scrollTop = l.scrollHeight; atBottomRef.current = true; }
+ // Not if the reader already moved: a slow node means this page
+ // lands seconds after the panel opened, and by then they may be
+ // reading somewhere else entirely.
+ if (l && arrivingRef.current) {
+ l.scrollTop = l.scrollHeight;
+ atBottomRef.current = true;
+ }
});
})
.catch(() => { loadedRef.current = false; });
@@ -284,27 +296,52 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
if (atBottomRef.current) list.scrollTop = list.scrollHeight;
}, [messages, hasMore]);
- // Keep the view pinned to the newest message while the reader is at the
- // bottom, through everything that grows the content *after* the initial
- // paint: attachment thumbnails and link-preview cards fetched over the
- // network, late layout, and the panel resizing itself with `fit()` below.
- // Without this, opening the Chat tab reliably lands a screen or two above
- // the last message — the layout effect above ran when the list was still
- // short. Does nothing once the reader scrolls up (`atBottomRef` is false),
- // so it never fights reading back or the "load older" anchor.
+ // Hold the arrival at the newest message through everything that grows the
+ // content *after* the initial paint: attachment thumbnails and link-preview
+ // cards fetched over the network, late layout, and the panel resizing itself
+ // with `fit()` below. Without this, opening the Chat tab reliably lands a
+ // screen or two above the last message — the layout effect above ran when
+ // the list was still short.
+ //
+ // Bounded by `arrivingRef`, not by `atBottomRef` alone. The at-bottom test
+ // has a 40px tolerance and is fed by an event delivered a frame late, so on
+ // its own it let a resize storm hold the reader against the end of the
+ // conversation with no way back up it. Once the reader has asked to move,
+ // this stops observing entirely.
useEffect(() => {
const list = listRef.current;
- if (!list || typeof ResizeObserver === 'undefined') return;
+ if (!list || typeof ResizeObserver === 'undefined' || !arrivingRef.current) return;
+ // Declared before `stick` closes over it: a `const` further down would be
+ // in its temporal dead zone here, which is the hook-ordering trap this
+ // codebase has already paid for once.
+ let ro = null;
const stick = () => {
+ if (!arrivingRef.current) { if (ro) ro.disconnect(); return; }
if (atBottomRef.current) list.scrollTop = list.scrollHeight;
};
- const ro = new ResizeObserver(stick);
+ ro = new ResizeObserver(stick);
ro.observe(list);
for (const child of list.children) ro.observe(child);
stick();
return () => ro.disconnect();
}, [messages]);
+ // The reader taking hold of the scroll ends the arrival, and it has to be
+ // recorded here rather than in `onScroll`: a `scroll` event is delivered at
+ // the next rendering step, so anything that pins in between undoes the
+ // movement before the handler that would have stopped it ever runs. These
+ // fire with the gesture itself.
+ useEffect(() => {
+ const list = listRef.current;
+ if (!list) return;
+ const release = () => { arrivingRef.current = false; };
+ const events = ['wheel', 'touchmove', 'pointerdown', 'keydown'];
+ for (const name of events) list.addEventListener(name, release, { passive: true });
+ return () => {
+ for (const name of events) list.removeEventListener(name, release, { passive: true });
+ };
+ }, []);
+
// Entering the Chat tab should leave the cursor in the composer, ready to
// type — the panel mounts fresh on every tab switch, so a mount effect is
// the tab-entry hook. `preventScroll` because the layout effects above are
@@ -332,7 +369,6 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
// Document-relative, so a page that happens to be scrolled does not skew
// the result — the answer must be the same either way.
const top = el.getBoundingClientRect().top + window.scrollY;
- el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`;
// What sits *below* the panel is not knowable from up here — today it is
// `.main`'s 24px bottom padding against this 16px gap, which left the
// document 8px taller than the window and a scrollbar on the chat tab at
@@ -340,10 +376,28 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
// change to the page break it again, the leftover is measured and taken
// off. Self-correcting: anything added under the panel is absorbed the
// same way.
+ //
+ // Remembered on the element rather than re-derived on every call, because
+ // this runs on `resize` and so can *cause* the event it listens for.
+ // Setting the naive height, reading the overflow back and subtracting it
+ // means the document alternately does and does not overflow the window: a
+ // page scrollbar appears and vanishes with it, `visualViewport` fires
+ // `resize` at every pass, and fit() re-enters itself for the life of the
+ // panel. Measured 2026-09-01 on a page nobody was touching: 240 firings
+ // in two seconds, against 2 for a bare document. Each one re-pinned the
+ // list to the bottom, so every attempt to scroll up was undone inside the
+ // same frame — before the `scroll` event that would have recorded it was
+ // even delivered, which is why the reader could not move and the "jump to
+ // latest" button never appeared. Converged, this writes nothing.
+ const below = el._chatFitBelow || CHAT_BOTTOM_GAP;
+ const target = Math.max(CHAT_MIN_HEIGHT, vh - top - below);
+ if (Math.abs(target - el.getBoundingClientRect().height) > 0.5) {
+ el.style.height = `${target}px`;
+ }
const over = document.documentElement.scrollHeight - vh;
if (over > 0) {
- el.style.height =
- `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`;
+ el._chatFitBelow = below + over;
+ el.style.height = `${Math.max(CHAT_MIN_HEIGHT, target - over)}px`;
}
};
const fitAndPin = () => {
@@ -351,13 +405,19 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const l = listRef.current;
if (l && atBottomRef.current) l.scrollTop = l.scrollHeight;
};
+ // A real viewport change can also mean the page below the panel reflowed,
+ // so the learnt leftover is dropped and measured again. Not on
+ // `visualViewport`: an Android keyboard changes the viewport, not the
+ // layout under the panel, and re-learning there would put the oscillation
+ // back on the one platform that fires that event constantly.
+ const refit = () => { el._chatFitBelow = 0; fitAndPin(); };
fitAndPin();
- window.addEventListener('resize', fitAndPin);
- window.addEventListener('orientationchange', fitAndPin);
+ window.addEventListener('resize', refit);
+ window.addEventListener('orientationchange', refit);
window.visualViewport?.addEventListener('resize', fitAndPin);
return () => {
- window.removeEventListener('resize', fitAndPin);
- window.removeEventListener('orientationchange', fitAndPin);
+ window.removeEventListener('resize', refit);
+ window.removeEventListener('orientationchange', refit);
window.visualViewport?.removeEventListener('resize', fitAndPin);
};
}, []);
@@ -366,6 +426,9 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const el = e.target;
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
atBottomRef.current = bottom;
+ // Belt and braces for a scroll no gesture listener saw — a scrollbar
+ // dragged from outside the list, a "find in page" jump, assistive tech.
+ if (!bottom) arrivingRef.current = false;
setAtBottom(bottom);
if (bottom) setUnreadFrom(null);
}, []);
diff --git a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py
new file mode 100644
index 0000000..7373976
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""
+Can the reader scroll up in the chat, and does the panel sit still when nobody
+is touching it?
+
+`scroll_probe.py` answers "is the document taller than the window". This one
+mounts **the real `ChatPanel`** — the shipped module, not a model of it — in a
+browser, drives it the way a person does, and reports where the list ends up.
+
+It exists because the defect it was written for is invisible to every other kind
+of test here. `fit()` set the panel's height, read the document's overflow back
+and subtracted it, so the document alternately did and did not overflow the
+window; the page scrollbar appeared and vanished with it, `visualViewport` fired
+`resize` at each pass, and `fit()` is bound to that event. It therefore re-ran
+about 120 times a second for the life of the panel, re-pinning the list to the
+bottom every time — which undid each attempt to scroll up *inside the same
+frame*, before the `scroll` event that would have recorded it was delivered. The
+source reads as correct: every pin is guarded by "only if the reader is at the
+bottom", and the reader never got to stop being at the bottom.
+
+ chat_scroll_probe.py
+
+Prints JSON: an `idle` block (viewport-resize firings on a page nobody touches)
+and a `steps` list (scroll position after each stage of a reading session).
+"""
+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 = 8747
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+# A bare document fires a couple of these while it settles. Anything above this
+# is the panel driving itself.
+IDLE_RESIZE_CEILING = 20
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head>
+<body>
+<div class="layout"><div class="main">
+ <div class="group-header"><div><h2>a group</h2><p class="group-desc">a description</p></div></div>
+ <div class="group-tabs"><button class="group-tab active">Chat</button></div>
+ <div id="root"></div>
+</div></div>
+<script type="module">
+import { html, render, useRef } from '/vendor/htm-preact.js';
+import { ChatPanel } from '/chat-app.js';
+
+const N = 120;
+const now = Date.now() / 1000;
+const messages = [];
+for (let i = 0; i < N; i++) {
+ messages.push({
+ id: 'm' + i, sender_id: i % 3 ? 'someone' : 'me',
+ sender_name: i % 3 ? 'someone' : 'me',
+ // Every fifth message carries a link, so a preview card lands late and
+ // grows the list the way the real thing does.
+ payload: (i % 5 === 0 ? 'see https://example.invalid/p/' + i + ' ' : '')
+ + 'message number ' + i + ', long enough to give the bubble a height',
+ timestamp: now - (N - i) * 60, thread_id: null,
+ });
+}
+
+// Held back until the reader has scrolled up: the cards must not be able to
+// drag them back down.
+let previewsAnswer = false;
+const transport = {
+ connected: true,
+ onChat: null,
+ async fetchChatHistory({ before }) {
+ return before ? { messages: [], hasMore: false } : { messages, hasMore: true };
+ },
+ async sendChat() {},
+ async uploadFile() { return {}; },
+ fetchLinkPreview(url) {
+ return new Promise(res => {
+ const tick = () => previewsAnswer
+ ? res({ ok: true, site_name: 'Example', title: 'A title for ' + url,
+ description: 'A description long enough to add a couple of lines '
+ + 'to the card and grow the list content.' })
+ : setTimeout(tick, 100);
+ tick();
+ });
+ },
+};
+
+function Host() {
+ const transportRef = useRef(transport);
+ const gekRef = useRef(null);
+ return html`<${ChatPanel} transportRef=${transportRef} gekRef=${gekRef}
+ username="me" entries=${[]} status="connected" />`;
+}
+render(html`<${Host} />`, document.getElementById('root'));
+
+const out = { steps: [] };
+const list = () => document.querySelector('.chat-messages');
+
+function snap(label) {
+ const l = list();
+ out.steps.push({
+ label,
+ scrollTop: Math.round(l.scrollTop),
+ fromBottom: Math.round(l.scrollHeight - l.scrollTop - l.clientHeight),
+ panelHeight: Math.round(
+ document.querySelector('.chat-panel').getBoundingClientRect().height),
+ // The jump button is the only outward sign that the panel noticed the
+ // reader leave the bottom.
+ jumpButton: !!document.querySelector('.chat-jump'),
+ });
+}
+
+const wait = ms => new Promise(r => setTimeout(r, ms));
+
+(async () => {
+ await wait(1000);
+ snap('arrived');
+
+ // Nobody touches the page. A panel that resizes itself says so here.
+ let resizes = 0;
+ window.visualViewport?.addEventListener('resize', () => { resizes++; });
+ await wait(1500);
+ out.idle = { viewportResizes: resizes,
+ documentOverflow: document.documentElement.scrollHeight - window.innerHeight };
+ snap('after idle');
+
+ // Scrolling up, over several frames, with no synthetic `scroll` event: the
+ // browser fires the real one, a frame later, which is the whole point.
+ const l = list();
+ for (let i = 0; i < 6; i++) {
+ l.scrollTop -= 120;
+ await new Promise(r => requestAnimationFrame(r));
+ }
+ await wait(100);
+ snap('scrolled up');
+
+ // Now let the preview cards land: content grows above and below the reader.
+ previewsAnswer = true;
+ await wait(1200);
+ snap('previews landed');
+
+ transport.onChat({ id: 'live', sender_id: 'someone', sender_name: 'someone',
+ payload: 'a new message', timestamp: Date.now() / 1000 });
+ await wait(400);
+ snap('message arrived');
+
+ window.dispatchEvent(new Event('resize'));
+ await wait(400);
+ snap('window resized');
+
+ fetch('/log', { method: 'POST', body: JSON.stringify(out) });
+})();
+</script></body></html>"""
+
+
+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, ctype = PAGE.encode(), "text/html; charset=utf-8"
+ else:
+ path = (STATIC / self.path.lstrip("/")).resolve()
+ if not str(path).startswith(str(STATIC)) or not path.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ body = path.read_bytes()
+ ctype = ("text/css" if path.suffix == ".css"
+ else "text/javascript" if path.suffix == ".js"
+ else "application/octet-stream")
+ 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)
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory() as profile:
+ # Real time, not `--virtual-time-budget`: the defect is a feedback
+ # loop between layout and an event, and a virtual clock does not
+ # run it.
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,800",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_chat_scroll_up.py b/packages/meshbay-hub/tests/test_chat_scroll_up.py
new file mode 100644
index 0000000..6cafcdc
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_chat_scroll_up.py
@@ -0,0 +1,102 @@
+"""
+Reading back through the conversation must work.
+
+The scroll-to-bottom on arrival has been patched five times, and the sixth
+patch took the other side away: the panel re-pinned the list about 120 times a
+second, so a wheel gesture was undone in the frame it happened in and the older
+messages became unreachable. Every pin in the source is guarded by "only if the
+reader is at the bottom" — the reader simply never got to stop being at the
+bottom, because the `scroll` event that records it is delivered a frame after
+the pin that erased it.
+
+None of that is visible in the source, which is why this measures the real
+`ChatPanel` in a browser instead of reading `chat-app.js`. `test_chat_scroll_
+bottom.py` keeps the structural guards; this one keeps the behaviour.
+"""
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "chat_scroll_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 / "chat-app.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+# A bare document fires a couple of these as it settles. Anything above this is
+# the panel driving itself, which is what the loop looked like.
+IDLE_RESIZE_CEILING = 20
+
+# The harness scrolls up by six frames of 120px. Chrome's scroll anchoring
+# moves the reader with the content when preview cards land above them, so the
+# distance from the bottom is not expected to be exactly 720 afterwards — only
+# to stay well clear of it.
+SCROLLED_UP_FLOOR = 400
+
+
+@pytest.fixture(scope="module")
+def probe():
+ run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=180)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ data = json.loads(run.stdout.decode())
+ return data, {s["label"]: s for s in data["steps"]}
+
+
+def test_arrives_on_the_newest_message(probe):
+ """Opening the tab lands at the end of the conversation, after the late
+ layout and the panel sizing itself."""
+ _, steps = probe
+ assert steps["arrived"]["fromBottom"] < 40, (
+ "the chat must open on the newest message; it opened "
+ f"{steps['arrived']['fromBottom']}px above it")
+
+
+def test_the_panel_does_not_resize_itself(probe):
+ """fit() runs on `resize` and must not produce one. When it did, it re-ran
+ every frame and re-pinned the scroll with it."""
+ data, _ = probe
+ assert data["idle"]["viewportResizes"] <= IDLE_RESIZE_CEILING, (
+ f"{data['idle']['viewportResizes']} viewport resizes on a page nobody "
+ "touched -- fit() is feeding the event it listens for, and every pass "
+ "re-pins the chat to the bottom")
+ assert data["idle"]["documentOverflow"] <= 0, (
+ "the chat tab must not leave the document taller than the window")
+
+
+def test_scrolling_up_holds(probe):
+ """The gesture must survive the frame it happened in."""
+ _, steps = probe
+ assert steps["scrolled up"]["fromBottom"] >= SCROLLED_UP_FLOOR, (
+ "scrolling up was undone: the list came back to "
+ f"{steps['scrolled up']['fromBottom']}px from the bottom")
+ assert steps["scrolled up"]["jumpButton"], (
+ "the panel never noticed the reader leave the bottom -- the pin beat "
+ "the scroll event, so atBottom stayed true and no jump button appeared")
+
+
+def test_late_content_does_not_drag_the_reader_down(probe):
+ """Link previews and thumbnails arrive over the following seconds and grow
+ the list. That is what the arrival pin is for, and it must be over by
+ now."""
+ _, steps = probe
+ assert steps["previews landed"]["fromBottom"] >= SCROLLED_UP_FLOOR, (
+ "preview cards landing pulled the reader back to the bottom")
+
+
+def test_a_new_message_does_not_yank_the_reader_down(probe):
+ """Somebody writing while you read back marks the spot; it does not move
+ you."""
+ _, steps = probe
+ assert steps["message arrived"]["fromBottom"] >= SCROLLED_UP_FLOOR, (
+ "an incoming message pulled the reader away from what they were reading")
+
+
+def test_a_resize_does_not_yank_the_reader_down(probe):
+ """A window resize, a rotation, or a phone's URL bar collapsing."""
+ _, steps = probe
+ assert steps["window resized"]["fromBottom"] >= SCROLLED_UP_FLOOR, (
+ "a resize pulled the reader back to the bottom")
diff --git a/packages/meshbay-hub/tests/test_layout_responsive.py b/packages/meshbay-hub/tests/test_layout_responsive.py
index d7a680d..4ee07d0 100644
--- a/packages/meshbay-hub/tests/test_layout_responsive.py
+++ b/packages/meshbay-hub/tests/test_layout_responsive.py
@@ -145,12 +145,19 @@ def test_the_measurement_survives_a_scrolled_page(app):
def test_the_panel_refits_when_the_viewport_changes(app):
- # `fit` is wrapped by `fitAndPin` (which also keeps the view pinned to
- # the bottom on a resize) — that is what the listeners bind to.
+ # `fit` is wrapped by `fitAndPin` (which also keeps the view pinned to the
+ # bottom on a resize), and on the window by `refit`, which additionally
+ # forgets the learnt space below the panel — a real viewport change can
+ # mean the page under it reflowed. `visualViewport` deliberately does not
+ # forget: a phone fires that event constantly, and re-deriving the leftover
+ # there is what made fit() oscillate and weld the reader to the bottom of
+ # the conversation (see test_chat_scroll_up.py).
for event in ("resize", "orientationchange"):
- assert f"addEventListener('{event}', fitAndPin)" in app
+ assert f"addEventListener('{event}', refit)" in app
assert "visualViewport?.addEventListener('resize', fitAndPin)" in app
- assert "removeEventListener('resize', fitAndPin)" in app, "the listener must be released"
+ assert "removeEventListener('resize', refit)" in app, "the listener must be released"
+ assert "el._chatFitBelow = 0" in app, (
+ "a window resize must forget the learnt leftover and measure it again")
def test_the_css_floor_does_not_fight_the_measurement(css, app):