fix: sticky was disabled site-wide, and 100vh is wrong on iOS

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 00:46:06 +02:00
parent 816ab0a66c
commit b2ddee74dc
13 changed files with 49 additions and 15 deletions

View file

@ -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; }

View file

@ -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; }

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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 => {

View file

@ -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;
}

View file

@ -30,7 +30,7 @@ export default function ForgotPasswordPage() {
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ width: '100%', maxWidth: 400, padding: '0 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#2563eb' }}>🏥 PedsHub</div>

View file

@ -353,7 +353,7 @@ export default function LandingPage() {
}, [])
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
<div style={{ minHeight: '100dvh', background: 'var(--bg)' }}>
{/* Auth modal overlay */}
{authModal && (

View file

@ -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 {

View file

@ -30,7 +30,7 @@ export default function ResetPasswordPage() {
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ width: '100%', maxWidth: 400, padding: '0 16px' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#2563eb' }}>🏥 PedsHub</div>

View file

@ -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);

View file

@ -16,7 +16,7 @@ export default function VerifyEmailPage() {
}, [token])
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div style={{ minHeight: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9' }}>
<div className="card" style={{ maxWidth: 420, width: '100%', textAlign: 'center' }}>
{status === 'verifying' && (
<><div className="spinner" style={{ margin: '0 auto 16px' }}></div><p>Verifying your email...</p></>