fix: three things a phone got wrong

**Two menus, one button.** The burger became the session's question list while
a session was open, so the site menu and the question list took turns on the
same control and the one you wanted was the other one. The session now has its
own button at the left-hand end of the bar, where it always fits; the burger on
the right is always the site menu.

**The rail was drawn twice.** `.quiz-player.is-boxed .quiz-sidebar` sets
`display: flex`, which outranks the narrow-screen rule that hides it — so on a
phone the list of questions appeared squeezed into the page *and* in the
drawer.

**The tour pointed off-screen.** A coach mark for a target below the fold
explained something the reader could not see. The target is brought into view
first, centred so the card has somewhere to sit — and left alone when it is
already comfortably on screen, since scrolling then just jerks the page.

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 20:43:05 +02:00
parent 572bf6877c
commit bc29e74842
5 changed files with 84 additions and 9 deletions

View file

@ -80,6 +80,26 @@ export default function CoachMarks({ steps = [], onDone }) {
}
}, [place])
// Bring the target to the reader before pointing at it. On a phone the third
// step is usually below the fold, and a coach mark for something off-screen
// is a card explaining a thing that is not there you are told to look at
// the analysis and shown the top of the page.
//
// `block: 'center'` rather than `nearest`: the card sits under the target
// where there is room, so a target scrolled to the very bottom edge has
// nowhere to put its own explanation.
useEffect(() => {
if (!step) return
const node = document.querySelector(`[data-tour="${step.target}"]`)
if (!node?.scrollIntoView) return
const box = node.getBoundingClientRect()
const room = window.innerHeight
// Already comfortably in view: scrolling anyway would jerk the page under
// somebody who can see the thing perfectly well.
if (box.top >= 8 && box.bottom <= room - 8) return
node.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' })
}, [step])
useEffect(() => { card.current?.focus() }, [index])
const finish = useCallback(() => { remember(); onDone?.() }, [onDone])

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import CoachMarks, { tourSeen } from './CoachMarks'
@ -77,3 +77,33 @@ describe('the first-run walk', () => {
expect(onDone).toHaveBeenCalled()
})
})
it('brings a target below the fold to the reader before pointing at it', async () => {
// On a phone the last step is usually off-screen, and a coach mark for
// something nobody can see is a card explaining a thing that is not there.
const seen = []
const target = document.createElement('div')
target.setAttribute('data-tour', 'below')
target.getBoundingClientRect = () => ({ top: 1400, left: 0, bottom: 1480, right: 300, width: 300, height: 80 })
target.scrollIntoView = (options) => seen.push(options)
document.body.appendChild(target)
render(<CoachMarks steps={[{ target: 'below', title: 'Down here', body: 'The analysis.' }]} onDone={() => {}} />)
await waitFor(() => expect(seen).toHaveLength(1))
expect(seen[0]).toMatchObject({ block: 'center' })
target.remove()
})
it('leaves the page alone when the target is already in view', async () => {
const seen = []
const target = document.createElement('div')
target.setAttribute('data-tour', 'here')
target.getBoundingClientRect = () => ({ top: 100, left: 0, bottom: 180, right: 300, width: 300, height: 80 })
target.scrollIntoView = (options) => seen.push(options)
document.body.appendChild(target)
render(<CoachMarks steps={[{ target: 'here', title: 'Right here', body: 'Visible.' }]} onDone={() => {}} />)
await screen.findByText('Right here')
expect(seen).toHaveLength(0)
target.remove()
})

View file

@ -281,6 +281,16 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
for stays put; where you are going gets out of the way while you read. */}
<div className="navbar-primary">
<div className="container navbar-inner">
{/* While a session is open on a phone, the questions get their own
control on the left and the site menu keeps the right. One burger
that changed meaning depending on where you were meant the two
menus took turns, and the one you wanted was the other one. */}
{sessionDrawer.opener && (
<button type="button" className="nav-session-burger"
aria-label="Questions in this session" onClick={sessionDrawer.opener}>
<span aria-hidden="true"></span>
</button>
)}
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}
onMouseEnter={() => setPeek(true)} onFocus={() => setPeek(true)}>🏥 PedsHub</Link>
{user && <GlobalSearch onOpenOverlay={onSearch} />}
@ -290,16 +300,15 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
<FeedbackBadge />
<JobsBadge jobs={jobs} />
<AccountMenu user={user} onLogout={logout} />
{/* While a session is open on a narrow screen, this button
belongs to the session: it opens the list of questions, and
the site menu is a tab inside that drawer. Two menu buttons
an inch apart, one of which leaves the session you are
sitting, is the wrong offer. */}
{/* Always the site menu. It used to become the session's list of
questions while a session was open, which put both menus on
the same button and neither where the thumb expected it; the
session now has its own control at the other end of the bar. */}
<button
className="nav-burger"
onClick={() => (sessionDrawer.opener ? sessionDrawer.opener() : setMenuOpen(v => !v))}
aria-label={sessionDrawer.opener ? 'Session menu' : 'Menu'}
aria-expanded={sessionDrawer.opener ? undefined : menuOpen}
onClick={() => setMenuOpen(v => !v)}
aria-label="Menu"
aria-expanded={menuOpen}
>
<span style={{
display: 'block', width: 22, height: 2, background: 'currentColor',

View file

@ -218,6 +218,15 @@ html, body { overflow-x: hidden; }
@media (prefers-reduced-motion: reduce) { .navbar-sections { transition: none; } }
[data-theme="markdown"] .navbar .logo { color: #d4a96a; }
/* The session's own control, at the left-hand end of the bar. Off the desktop
layout entirely: there the list of questions is a rail beside the question,
and a button for it would open a drawer over something already on screen. */
.nav-session-burger {
display: none; margin-right: 2px; padding: 6px 8px; cursor: pointer;
border: 1px solid rgba(255, 255, 255, .22); border-radius: 8px;
background: none; color: var(--navbar-fg); font-size: 1rem; line-height: 1;
}
.nav-session-burger:hover { border-color: rgba(255, 255, 255, .45); }
.nav-burger { display: none; background: none !important; border: 0 !important; cursor: pointer; color: var(--navbar-fg); padding: 6px; flex-direction: column; gap: 5px; opacity: 0.85; }
/* Scoped to the dark bar. These were `.navbar a` and `.navbar button`, which
also caught everything in the light section bar beneath it near-white text
@ -801,6 +810,7 @@ html, body { overflow-x: hidden; }
/* The burger carries the sections on a phone; the strip would be a second
copy of the same links competing for the same thumb. */
.nav-burger { display: flex; }
.nav-session-burger { display: inline-flex; align-items: center; }
.nav-logout { display: none; }
/* Hide the whole strip, wrapper and arrows, not just the link row. */
.navbar-sections .ss-wrap { display: none; }

View file

@ -427,6 +427,12 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
/* One column; the question scrolls and the bar stays. */
.quiz-player.is-boxed .quiz-layout > * { overflow-y: visible; }
.quiz-player.is-boxed .quiz-layout { overflow-y: auto; }
/* And no rail. The narrow rule that hides it is `.quiz-player
.quiz-sidebar`, which the boxed player's own `display: flex` outranks
so on a phone the list of questions was drawn twice: once squeezed into
the page above the question, and again in the drawer the burger opens.
Same specificity here, later in the file, so this one wins. */
.quiz-player.is-boxed .quiz-sidebar { display: none; }
}
@media (max-width: 640px) {
.quiz-player.is-boxed { height: calc(100dvh - 90px); }