From b282e42ae825f70b6b5b4074a251257fa0036a18 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Tue, 31 Mar 2026 05:54:41 -0700 Subject: [PATCH] fix: mobile viewport cutoff (100dvh) + fit view content anchoring (flex-end) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile: 100vh includes browser chrome on mobile — bottom row clipped. Fix: 100dvh with 100vh fallback (progressive enhancement) in .view and .session-tile--expanded. Fit view: scrollTop hack reset every 2s by poll cycle and had no effect when content didn't overflow. Fix: replace position:absolute+scrollTop with flex+justify-content:flex-end on tile-body. Content naturally anchors to bottom without JS. No scrollbar hiding needed. Tests: remove test_fit_view_pre_has_top_zero (old behavior), add test_view_uses_dvh_fallback, test_fit_view_tile_body_uses_flex_end, test_fit_view_pre_is_static. Update JS test for new flex approach. --- muxplex/frontend/app.js | 5 +- muxplex/frontend/style.css | 24 ++++++---- muxplex/frontend/tests/test_app.mjs | 13 ++++-- muxplex/tests/test_frontend_css.py | 72 +++++++++++++++++++++++++---- 4 files changed, 88 insertions(+), 26 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 70c8fe5..e693798 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -656,10 +656,7 @@ function renderGrid(sessions) { grid.classList.add('session-grid--fit'); requestAnimationFrame(function() { applyFitLayout(grid); - // Scroll each tile's pre to the bottom so content anchors at the bottom (like a real terminal) - grid.querySelectorAll('.tile-body pre').forEach(function(pre) { - pre.scrollTop = pre.scrollHeight; - }); + // No scrollTop hack needed — CSS flex + justify-content:flex-end anchors content to bottom }); } diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index cccbe07..ca9c3f0 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -83,7 +83,8 @@ body { ============================================================ */ .view { - height: 100vh; + height: 100vh; /* fallback for browsers without dvh support */ + height: 100dvh; /* dynamic viewport height — adjusts for mobile browser chrome */ overflow: hidden; } @@ -593,7 +594,8 @@ body { top: 0 !important; left: 0 !important; width: 100vw !important; - height: 100vh !important; + height: 100vh !important; /* fallback for browsers without dvh support */ + height: 100dvh !important; /* dynamic viewport height — adjusts for mobile browser chrome */ border-radius: 0; } @@ -1380,16 +1382,18 @@ body { overflow: hidden; } -/* In fit mode, stretch pre to fill the full tile body height and anchor content to bottom */ -.session-grid--fit .tile-body pre { - top: 0; /* stretch to fill full tile body height in fit mode */ - overflow-y: scroll; /* enable scrollTop positioning (content anchored via JS scrollTop=scrollHeight) */ - scrollbar-width: none; /* Firefox: hide scrollbar */ - -ms-overflow-style: none; /* IE/Edge: hide scrollbar */ +/* In fit mode, tile body uses flex to anchor content at the bottom (like a real terminal) */ +.session-grid--fit .tile-body { + display: flex; + flex-direction: column; + justify-content: flex-end; /* content anchored to bottom */ } -.session-grid--fit .tile-body pre::-webkit-scrollbar { - display: none; /* Chrome/Safari: hide scrollbar */ +/* In fit mode, pre participates in flex layout (not absolute-positioned) */ +.session-grid--fit .tile-body pre { + position: static; /* remove absolute positioning — flex layout controls position */ + max-height: 100%; /* don't overflow the tile body */ + overflow: hidden; /* clip excess content at the top */ } /* ============================================================ diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 34d6650..514a401 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -2234,10 +2234,17 @@ test('buildTileHTML shows up to 80 lines in fit mode', () => { ); }); -test('CSS style.css has scrollbar-width none for fit mode pre to hide scrollbar', () => { +test('CSS style.css uses flex layout (not scrollbar hack) for fit mode content anchoring', () => { const source = fs.readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + // New approach: flex + justify-content:flex-end anchors content to bottom without JS scrollTop hacks assert.ok( - source.includes('scrollbar-width: none'), - 'style.css must have scrollbar-width: none for hidden scrollbar in fit mode pre' + source.includes('.session-grid--fit .tile-body'), + 'style.css must have .session-grid--fit .tile-body rule for flex layout' ); + assert.ok( + source.includes('justify-content: flex-end') || source.includes('justify-content:flex-end'), + 'style.css must use justify-content: flex-end to anchor fit mode content at bottom' + ); + // Old scrollbar-hiding hack is no longer needed with flex approach + // (scrollbar-width: none was only needed when overflow-y:scroll was used for scrollTop positioning) }); diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 94fd6c1..1ab7158 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -1712,19 +1712,73 @@ def test_css_no_compact_tile_height() -> None: # ============================================================ -def test_fit_view_pre_has_top_zero() -> None: - """In fit mode, .tile-body pre must have top:0 to fill the full tile height. +# ============================================================ +# Mobile viewport + fit view content anchoring fixes +# ============================================================ - Bug: .tile-body pre uses position:absolute with bottom:0 but no top:0. - In fit mode where tiles are taller than auto mode, the pre is anchored - to the bottom but only takes natural content height, leaving a black gap above. - Fix: .session-grid--fit .tile-body pre { top: 0 } stretches it to fill the tile. + +def test_view_uses_dvh_fallback() -> None: + """Bug fix: .view must use 100dvh (dynamic viewport height) for mobile. + + On mobile browsers, 100vh includes the browser chrome (address bar + bottom nav), + causing the bottom row of tiles to be cut off under overflow:hidden. + Fix: height: 100dvh with 100vh fallback (progressive enhancement). + The 100vh MUST appear before 100dvh (browsers ignore unknown values, so + 100dvh overrides 100vh for browsers that support it). + """ + css = read_css() + # .view block must contain 100dvh + block = _extract_rule_block(css, ".view {") + assert "100dvh" in block, ( + ".view must use height: 100dvh for mobile — 100vh includes browser chrome, " + "causing the bottom row to be cut off on mobile devices" + ) + # 100vh must still be present as fallback (appears before 100dvh in file) + assert "100vh" in block, ( + ".view must keep height: 100vh as fallback for browsers without dvh support" + ) + # Verify order: 100vh must come before 100dvh in the block (fallback first) + assert block.index("100vh") < block.index("100dvh"), ( + "height: 100vh (fallback) must appear BEFORE height: 100dvh in .view rule — " + "browsers that don't support dvh will use the last valid value" + ) + + +def test_fit_view_tile_body_uses_flex_end() -> None: + """Fit mode tile-body must use flex + justify-content: flex-end for bottom anchoring. + + Bug: .tile-body pre had position:absolute + scrollTop=scrollHeight hack. + The scrollTop hack resets every 2s when innerHTML is rebuilt by the poll cycle, + and has no effect when content doesn't overflow. + Fix: make tile-body a flex container with justify-content:flex-end so the pre + naturally anchors to the bottom without JS. + """ + css = read_css() + assert ".session-grid--fit .tile-body" in css, ( + "Missing .session-grid--fit .tile-body rule — needed for flex-end bottom anchoring" + ) + block = _extract_rule_block(css, ".session-grid--fit .tile-body {") + assert "display: flex" in block or "display:flex" in block, ( + ".session-grid--fit .tile-body must use display: flex" + ) + assert "justify-content: flex-end" in block or "justify-content:flex-end" in block, ( + ".session-grid--fit .tile-body must use justify-content: flex-end to anchor content to bottom" + ) + + +def test_fit_view_pre_is_static() -> None: + """Fit mode pre must use position:static (not absolute) for flex layout. + + Bug: .tile-body pre uses position:absolute which takes it out of flex flow. + In fit mode, we need position:static so flex layout controls the pre's position. + The parent .tile-body is a flex column with justify-content:flex-end, so the + pre will naturally sit at the bottom. """ css = read_css() assert ".session-grid--fit .tile-body pre" in css, ( - "Missing .session-grid--fit .tile-body pre rule — needed to fix pre height in fit mode" + "Missing .session-grid--fit .tile-body pre rule" ) block = _extract_rule_block(css, ".session-grid--fit .tile-body pre {") - assert "top: 0" in block or "top:0" in block, ( - ".session-grid--fit .tile-body pre must have top: 0 to fill full tile body height" + assert "position: static" in block or "position:static" in block, ( + ".session-grid--fit .tile-body pre must have position: static to participate in flex layout" )