From bc29e74842543be8f0592330725ddee16102a977 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 20:43:05 +0200 Subject: [PATCH] fix: three things a phone got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/CoachMarks.jsx | 20 +++++++++++++ frontend/src/components/CoachMarks.test.jsx | 32 ++++++++++++++++++++- frontend/src/components/Navbar.jsx | 25 ++++++++++------ frontend/src/index.css | 10 +++++++ frontend/src/pages/QuizPlayer.css | 6 ++++ 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/CoachMarks.jsx b/frontend/src/components/CoachMarks.jsx index b5a763d..84c9271 100644 --- a/frontend/src/components/CoachMarks.jsx +++ b/frontend/src/components/CoachMarks.jsx @@ -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]) diff --git a/frontend/src/components/CoachMarks.test.jsx b/frontend/src/components/CoachMarks.test.jsx index 80586fa..dec8728 100644 --- a/frontend/src/components/CoachMarks.test.jsx +++ b/frontend/src/components/CoachMarks.test.jsx @@ -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( {}} />) + 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( {}} />) + await screen.findByText('Right here') + expect(seen).toHaveLength(0) + target.remove() +}) diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index db8bc91..a231e2e 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -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. */}
+ {/* 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 && ( + + )} setMenuOpen(false)} onMouseEnter={() => setPeek(true)} onFocus={() => setPeek(true)}>🏥 PedsHub {user && } @@ -290,16 +300,15 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) { - {/* 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. */}