From b2ddee74dc0236440748e93b0e6d56a934726f8c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 00:46:06 +0200 Subject: [PATCH] fix: sticky was disabled site-wide, and 100vh is wrong on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `html, body { overflow-x: hidden }` makes both a scroll container, and a `position: sticky` descendant then sticks to that rather than to the viewport — which is to say it does not stick at all. Every sticky thing in the app was affected: the session rail, the settings nav, the article column, the study-plan rail. `overflow-x: clip` does the same job without becoming a scroll container. Hidden stays as the fallback, so a browser without `clip` still cannot be scrolled sideways and only loses stickiness, which is the lesser fault. Putting the rail away destroyed the layout. The collapsed grid was `0 minmax(0, 1fr)` and the rail is `display: none`, so the content became the *first* grid item and landed in the zero-width column — wrapping one word per line beside an empty page. One column when there is one thing in it. 100vh is the largest viewport on iOS — the one with the URL bar hidden — so anything sized to it is taller than the screen really is and its bottom sits behind the bar. Eleven files now use 100dvh, which tracks the viewport as it changes. The AI Mode composer was the one bottom-sticky bar with no safe-area inset; its send button sat under the home indicator. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/AnalysisShell.css | 5 ++++- frontend/src/components/ArticleSplitPane.css | 4 ++-- frontend/src/components/TeachChat.jsx | 2 +- frontend/src/index.css | 15 +++++++++++++-- frontend/src/pages/AiModePage.css | 9 +++++++-- frontend/src/pages/AnalysisSessionPage.test.jsx | 15 +++++++++++++++ frontend/src/pages/ArticlesPage.css | 2 +- frontend/src/pages/ForgotPasswordPage.jsx | 2 +- frontend/src/pages/LandingPage.jsx | 2 +- frontend/src/pages/QuizPlayer.css | 2 +- frontend/src/pages/ResetPasswordPage.jsx | 2 +- frontend/src/pages/StudyPlanBlockPage.css | 2 +- frontend/src/pages/VerifyEmailPage.jsx | 2 +- 13 files changed, 49 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/AnalysisShell.css b/frontend/src/components/AnalysisShell.css index 4d1f955..2d7eaec 100644 --- a/frontend/src/components/AnalysisShell.css +++ b/frontend/src/components/AnalysisShell.css @@ -16,7 +16,10 @@ margin-inline: calc(50% - 50vw); padding-inline: max(16px, calc(50vw - 660px)); } -.ax-layout.is-collapsed { grid-template-columns: 0 minmax(0, 1fr); gap: 0; } +/* One column when the rail is away. It was `0 minmax(0, 1fr)`, and since the + rail is `display: none` the content became the *first* grid item — landing + in the zero-width column and wrapping one word per line. */ +.ax-layout.is-collapsed { grid-template-columns: minmax(0, 1fr); gap: 0; } .ax-main { min-width: 0; padding: 8px 0 48px; } diff --git a/frontend/src/components/ArticleSplitPane.css b/frontend/src/components/ArticleSplitPane.css index 78500d3..585a2fa 100644 --- a/frontend/src/components/ArticleSplitPane.css +++ b/frontend/src/components/ArticleSplitPane.css @@ -15,11 +15,11 @@ cross-reference must not move the article you were reading. */ .article-split.is-open > .article-split-main, .article-split.is-open > .article-split-pane { - max-height: calc(100vh - 120px); + max-height: calc(100dvh - 120px); overflow-y: auto; } /* The rail is sticky inside its own pane now, not inside the window. */ -.article-page.is-split .article-sections { top: 6px; max-height: calc(100vh - 200px); } +.article-page.is-split .article-sections { top: 6px; max-height: calc(100dvh - 200px); } .article-page.is-split .article-layout { grid-template-columns: 190px 1fr; gap: 14px; } .article-page.is-split .article-content { padding: 16px 18px; } diff --git a/frontend/src/components/TeachChat.jsx b/frontend/src/components/TeachChat.jsx index 98e7daa..6941503 100644 --- a/frontend/src/components/TeachChat.jsx +++ b/frontend/src/components/TeachChat.jsx @@ -304,7 +304,7 @@ export default function TeachChat({ question, attemptId, elevated = false }) { left: auto !important; top: 0 !important; width: 340px !important; - height: 100vh !important; + height: 100dvh !important; border-radius: 0 !important; border-top: none !important; border-left: 2px solid var(--primary) !important; diff --git a/frontend/src/index.css b/frontend/src/index.css index c65e3ac..1dc30f2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -114,7 +114,18 @@ body { too wide then scrolls the whole page sideways — which is why the navbar could be found clipped mid-word at its left edge. Anything genuinely wider than the window scrolls inside its own box instead. */ -html, body { overflow-x: hidden; max-width: 100%; } +html, body { max-width: 100%; } +/* `overflow-x: hidden` on an ancestor silently disables `position: sticky` for + every descendant — it makes the element a scroll container, and a sticky + child sticks to that instead of to the viewport. That is why the session + rail, the settings nav and the article column would not stay put. `clip` + does the same job without becoming a scroll container. Hidden is kept as + the fallback, so a browser without `clip` still cannot be scrolled + sideways; it just loses stickiness, which is the lesser fault. */ +html, body { overflow-x: hidden; } +@supports (overflow-x: clip) { + html, body { overflow-x: clip; } +} /* ── Layout ─────────────────────────────────────────────────── */ .container { max-width: 1200px; margin: 0 auto; padding: 0 28px; } @@ -732,7 +743,7 @@ html, body { overflow-x: hidden; max-width: 100%; } border-radius: var(--card-radius); padding: 16px; box-shadow: var(--card-shadow); - max-height: calc(100vh - 100px); + max-height: calc(100dvh - 100px); overflow-y: auto; } diff --git a/frontend/src/pages/AiModePage.css b/frontend/src/pages/AiModePage.css index d28e37c..5aa0d00 100644 --- a/frontend/src/pages/AiModePage.css +++ b/frontend/src/pages/AiModePage.css @@ -3,7 +3,7 @@ .ai-page { display: grid; grid-template-columns: 230px 1fr; gap: 18px; align-items: start; max-width: 1060px; margin: 0 auto; } .ai-rail { - position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto; + position: sticky; top: 76px; max-height: calc(100dvh - 100px); overflow-y: auto; background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 12px; } @@ -72,7 +72,12 @@ .ai-error { color: var(--wrong-fg); font-size: 0.85rem; margin: 0; } -.ai-composer { display: flex; gap: 8px; align-items: flex-end; position: sticky; bottom: 0; padding-bottom: 12px; background: var(--bg); } +.ai-composer { + display: flex; gap: 8px; align-items: flex-end; + position: sticky; bottom: 0; background: var(--bg); + /* Clear of the home indicator, or the send button sits under it. */ + padding-bottom: calc(12px + env(safe-area-inset-bottom)); +} .ai-composer textarea { flex: 1; min-width: 0; resize: vertical; padding: 11px 14px; border: 1px solid var(--border); border-radius: 10px; diff --git a/frontend/src/pages/AnalysisSessionPage.test.jsx b/frontend/src/pages/AnalysisSessionPage.test.jsx index 8252d30..8742fed 100644 --- a/frontend/src/pages/AnalysisSessionPage.test.jsx +++ b/frontend/src/pages/AnalysisSessionPage.test.jsx @@ -138,6 +138,21 @@ describe('a session whose only attempt is still in progress', () => { }) }) +describe('the session rail', () => { + it('gives the content the whole width when the rail is put away', async () => { + mountAttempt() + await screen.findByRole('complementary', { name: 'Sessions' }) + const layout = document.querySelector('.ax-layout') + expect(layout).not.toHaveClass('is-collapsed') + + await userEvent.click(screen.getByRole('button', { name: /Hide|Collapse/i })) + // Hiding the rail used to leave the content in a zero-width grid column, + // wrapping one word per line beside an empty page. + expect(document.querySelector('.ax-layout')).toHaveClass('is-collapsed') + expect(document.querySelector('.ax-main')).toBeInTheDocument() + }) +}) + describe('a study session with every question answered', () => { it('becomes the review without waiting to be handed in', async () => { api.get.mockImplementation(url => { diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index 11ee83e..b794aa8 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -20,7 +20,7 @@ its whole length, scrolling on their own when there are more sections than screen. */ .article-sections { - position: sticky; top: 76px; max-height: calc(100vh - 96px); overflow-y: auto; + position: sticky; top: 76px; max-height: calc(100dvh - 96px); overflow-y: auto; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 14px; } diff --git a/frontend/src/pages/ForgotPasswordPage.jsx b/frontend/src/pages/ForgotPasswordPage.jsx index 6ec80d5..47a4de7 100644 --- a/frontend/src/pages/ForgotPasswordPage.jsx +++ b/frontend/src/pages/ForgotPasswordPage.jsx @@ -30,7 +30,7 @@ export default function ForgotPasswordPage() { } return ( -
+
🏥 PedsHub
diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index b6bc6e0..59e7e5b 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -353,7 +353,7 @@ export default function LandingPage() { }, []) return ( -
+
{/* Auth modal overlay */} {authModal && ( diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index b985eb2..fd156f2 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -7,7 +7,7 @@ /* Session rail beside the question, like a Qbank session's left column. Below 1150px there is not room for both, so the topbar dropdown takes over. */ .quiz-player .quiz-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 28px; align-items: start; } -.quiz-player .quiz-sidebar { display: block; order: -1; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow-y: auto; } +.quiz-player .quiz-sidebar { display: block; order: -1; position: sticky; top: 16px; max-height: calc(100dvh - 32px); overflow-y: auto; } .quiz-rail-head { font-size: .68rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: #8b929c; padding: 0 0 8px; } .quiz-rail-list { display: flex; flex-direction: column; } .quiz-rail-item { diff --git a/frontend/src/pages/ResetPasswordPage.jsx b/frontend/src/pages/ResetPasswordPage.jsx index 8010263..1f5fba8 100644 --- a/frontend/src/pages/ResetPasswordPage.jsx +++ b/frontend/src/pages/ResetPasswordPage.jsx @@ -30,7 +30,7 @@ export default function ResetPasswordPage() { } return ( -
+
🏥 PedsHub
diff --git a/frontend/src/pages/StudyPlanBlockPage.css b/frontend/src/pages/StudyPlanBlockPage.css index 5c9f93a..01c1d1c 100644 --- a/frontend/src/pages/StudyPlanBlockPage.css +++ b/frontend/src/pages/StudyPlanBlockPage.css @@ -7,7 +7,7 @@ } /* ── Blocks rail ─────────────────────────────────────────────────── */ -.spb-rail { position: sticky; top: 76px; align-self: start; max-height: calc(100vh - 100px); overflow-y: auto; } +.spb-rail { position: sticky; top: 76px; align-self: start; max-height: calc(100dvh - 100px); overflow-y: auto; } .spb-rail-plan { display: block; padding: 12px 14px; font-weight: 700; font-size: .95rem; color: var(--text); text-decoration: none; border-bottom: 1px solid var(--border); diff --git a/frontend/src/pages/VerifyEmailPage.jsx b/frontend/src/pages/VerifyEmailPage.jsx index 728d954..6264531 100644 --- a/frontend/src/pages/VerifyEmailPage.jsx +++ b/frontend/src/pages/VerifyEmailPage.jsx @@ -16,7 +16,7 @@ export default function VerifyEmailPage() { }, [token]) return ( -
+
{status === 'verifying' && ( <>

Verifying your email...