From 8e3cce8025da5c354f8f69f6522eeb4f12bd2d24 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 25 Jun 2026 05:47:08 +0000 Subject: [PATCH] test: class-level regression guard for the #4856 mobile-transcript-collapse family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4856 (mobile transcript completely unscrollable) survived multiple fixes because regressions were guarded only by grepping one exact CSS string. This adds an INVARIANT guard so the whole class can't recur: .messages-inner (base + every media query) must never become a Y-axis scroll container — neither via an explicit overflow-y scroll value nor via overflow-x:hidden/scroll/auto (which the CSS spec coerces overflow-y->auto, collapsing min-height:auto to 0 inside the flex column). overflow-x:clip is the only allowed horizontal clip. Also asserts .messages stays the real scroller (overflow-y:auto + min-height:0). Proven non-vacuous: reintroducing overflow-x:hidden on the mobile rule fails it. For a true runtime check at a real viewport, the private workspace has scripts/layout_scroll_probe.py. --- tests/test_transcript_scroll_invariant.py | 136 ++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/test_transcript_scroll_invariant.py diff --git a/tests/test_transcript_scroll_invariant.py b/tests/test_transcript_scroll_invariant.py new file mode 100644 index 00000000..ff480f09 --- /dev/null +++ b/tests/test_transcript_scroll_invariant.py @@ -0,0 +1,136 @@ +"""Class-level regression guard for the #4856 mobile-transcript-collapse family. + +#4856 was a long-lived, high-impact mobile bug: the transcript was completely +unscrollable on phones. It survived multiple "fixes" because the regressions +were guarded only by grepping for one exact CSS string. This test guards the +*invariant* that actually matters, so the whole CLASS of bug can't come back: + + THE INVARIANT + ------------- + In the transcript layout, `.messages` is the scroll container (it owns + `overflow-y:auto` + `min-height:0`). `.messages-inner` is its child flex item + that must GROW to the transcript's full height so the content overflows the + *scroller*, not the inner. If `.messages-inner` ever becomes a scroll + container on the Y axis, its `min-height:auto` resolves to 0, it collapses + inside the flex column, and the transcript becomes unscrollable. + + `.messages-inner` becomes a Y scroll container if it sets EITHER: + - `overflow-y: auto|scroll|hidden|clip`, OR + - `overflow-x: hidden|scroll|auto` while overflow-y is visible — because per + the CSS overflow spec, when one axis is hidden/scroll/auto and the other + is `visible`, the visible axis is computed to `auto`. (`overflow-x: clip` + is the ONLY horizontal-clipping value that does NOT coerce overflow-y.) + +So: every `.messages-inner` rule (base and every media query) may use +`overflow-x: clip` to clip horizontal overflow, but must never use +`overflow-x: hidden` (or any overflow-y scroll value). And `.messages` must +remain the real scroller. + +This is a pure source-invariant test (no browser needed in CI). For a true +runtime check at a real viewport, use the private workspace tool +`scripts/layout_scroll_probe.py`. +""" +from __future__ import annotations + +import re +from pathlib import Path + +CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text(encoding="utf-8") + +# Values that, on overflow-x, coerce overflow-y -> auto (making a scroll +# container). `clip` and `visible` do not coerce. +_COERCING_OVERFLOW_X = ("hidden", "scroll", "auto") + + +def _all_rule_bodies(selector_substr: str) -> list[tuple[int, str]]: + """Return (offset, rule_body) for every CSS rule whose selector contains + selector_substr — across the base stylesheet AND all media queries.""" + out = [] + for m in re.finditer(r"([^{}]*\{[^{}]*\})", CSS): + block = m.group(1) + # selector is everything before the first '{' + sel = block[: block.index("{")] + if selector_substr in sel: + out.append((m.start(), block)) + return out + + +def _decls(rule_body: str) -> dict[str, str]: + inner = rule_body[rule_body.index("{") + 1 : rule_body.rindex("}")] + decls = {} + for part in inner.split(";"): + if ":" in part: + k, v = part.split(":", 1) + decls[k.strip().lower()] = v.strip().lower().replace("!important", "").strip() + return decls + + +def test_messages_inner_never_becomes_a_y_scroll_container(): + """Every .messages-inner rule (base + every media query) must not turn the + inner into a Y-axis scroll container — the #4856 collapse class.""" + rules = _all_rule_bodies(".messages-inner") + assert rules, ".messages-inner rules not found in style.css" + for _, body in rules: + # Only consider rules whose selector is exactly/primarily .messages-inner + sel = body[: body.index("{")] + if ".messages-inner" not in sel: + continue + d = _decls(body) + ox = d.get("overflow-x") + oy = d.get("overflow-y") + overflow = d.get("overflow") + # Explicit overflow-y scroll value on the inner is forbidden. + assert oy not in ("auto", "scroll", "hidden", "clip"), ( + f".messages-inner sets overflow-y:{oy} — that makes it a scroll " + f"container, min-height:auto->0, and collapses the transcript (#4856). " + f"Rule: {sel.strip()}" + ) + # A shorthand `overflow:` that implies a scroll value is forbidden. + if overflow is not None: + assert overflow in ("visible", "clip visible", "visible clip"), ( + f".messages-inner sets overflow:{overflow} — risks the #4856 " + f"scroll-container collapse. Rule: {sel.strip()}" + ) + # overflow-x must not be a coercing value (hidden/scroll/auto): those + # coerce overflow-y->auto when overflow-y is visible. clip is allowed. + if ox is not None: + assert ox not in _COERCING_OVERFLOW_X, ( + f".messages-inner sets overflow-x:{ox} — per the CSS spec this " + f"coerces overflow-y to auto, making the inner an unscrollable " + f"scroll container (the #4856 mobile-transcript collapse). Use " + f"overflow-x:clip to clip horizontally without coercion. " + f"Rule: {sel.strip()}" + ) + + +def test_messages_remains_the_transcript_scroll_container(): + """The transcript's real scroller is .messages: it must keep overflow-y:auto + and min-height:0 (so it can shrink in the flex column and scroll).""" + rules = [b for _, b in _all_rule_bodies(".messages") + if re.search(r"(^|[,\s]):?\.messages\{", b[: b.index("{") + 1].replace(" ", "")) + or b[: b.index("{")].strip().endswith(".messages")] + # Fall back: find the base `.messages{...}` rule directly. + base = re.search(r"(?