+
setMenuOpen(false)}>🏥 PedsHub
+ {user &&
}
- {user ? (
- <>
- {/* Desktop nav */}
-
- {navLinks.map(l => (
-
- {l.label}
-
- ))}
-
- Logout
-
-
- {/* Mobile: jobs + hamburger */}
-
+ {user ? (
+
+ Logout
setMenuOpen(v => !v)}
aria-label="Menu"
- style={{
- background: 'none', border: 'none', cursor: 'pointer', color: 'var(--navbar-fg)',
- padding: '6px', display: 'flex', flexDirection: 'column', gap: 5, opacity: 0.85,
- }}
+ aria-expanded={menuOpen}
>
- >
- ) : (
- /* Logged-out: Sign In + Register */
-
- {onSignIn
- ? Sign In
- : Sign In
- }
- {onRegister
- ? Register
- : Register
- }
-
- )}
+ ) : (
+ /* Logged-out: Sign In + Register */
+
+ {onSignIn
+ ? Sign In
+ : Sign In
+ }
+ {onRegister
+ ? Register
+ : Register
+ }
+
+ )}
+
+ {user && (
+ /* Focus-within keeps it open for a keyboard user tabbing into links
+ that are visually gone. */
+
+ )}
+
{/* Mobile dropdown */}
{user && menuOpen && (
({ default: { get: vi.fn(), post: vi.fn() } }))
+let currentUser = { id: 1, name: 'Learner', role: 'user' }
+const logout = vi.fn()
+vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: currentUser, logout }) }))
+vi.mock('./ExamSwitcher', () => ({ default: () =>
}))
+vi.mock('./GlobalSearch', () => ({ default: () =>
}))
+
+const mount = () => render(
)
+
+const scrollTo = async (y) => {
+ window.scrollY = y
+ await act(async () => {
+ window.dispatchEvent(new Event('scroll'))
+ // The listener defers to an animation frame so a burst of events costs one pass.
+ await new Promise(resolve => requestAnimationFrame(() => resolve()))
+ })
+}
+
+describe('two-bar header', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentUser = { id: 1, name: 'Learner', role: 'user' }
+ api.get.mockResolvedValue({ data: [] })
+ window.scrollY = 0
+ })
+
+ it('separates identity and search from where you are going', async () => {
+ mount()
+ const primary = document.querySelector('.navbar-primary')
+ expect(within(primary).getByLabelText('Search PedsHub')).toBeInTheDocument()
+ expect(within(primary).getByText('🏥 PedsHub')).toBeInTheDocument()
+
+ const sections = document.querySelector('.navbar-sections')
+ expect(within(sections).getByRole('navigation', { name: 'Sections' })).toBeInTheDocument()
+ expect(within(sections).getByTestId('exam-switcher')).toBeInTheDocument()
+ // The section links are not duplicated in the bar that never moves.
+ expect(within(primary).queryByRole('link', { name: 'Reading' })).not.toBeInTheDocument()
+ })
+
+ it('marks the section you are in', async () => {
+ mount()
+ const current = screen.getByRole('link', { name: 'Reading' })
+ expect(current).toHaveAttribute('aria-current', 'page')
+ expect(screen.getByRole('link', { name: 'Cards' })).not.toHaveAttribute('aria-current')
+ })
+
+ it('gets out of the way going down and comes back coming up', async () => {
+ mount()
+ const bar = document.querySelector('.navbar-sections')
+ expect(bar).not.toHaveClass('is-hidden')
+
+ await scrollTo(400)
+ expect(bar).toHaveClass('is-hidden')
+
+ // Scrolling up is the gesture that means "I am looking for something".
+ await scrollTo(320)
+ expect(bar).not.toHaveClass('is-hidden')
+ })
+
+ it('stays put near the top, so a short page never loses it', async () => {
+ mount()
+ const bar = document.querySelector('.navbar-sections')
+ await scrollTo(40)
+ expect(bar).not.toHaveClass('is-hidden')
+ })
+
+ it('ignores the jitter a trackpad produces', async () => {
+ mount()
+ const bar = document.querySelector('.navbar-sections')
+ await scrollTo(400)
+ expect(bar).toHaveClass('is-hidden')
+ await scrollTo(402) // under the threshold: not a decision to scroll up
+ expect(bar).toHaveClass('is-hidden')
+ })
+
+ it('shows moderator-only sections only to those who manage questions', async () => {
+ mount()
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith('/question-categories/my-grants'))
+ expect(screen.queryByRole('link', { name: 'Images' })).not.toBeInTheDocument()
+
+ currentUser = { id: 2, name: 'Mod', role: 'moderator' }
+ mount()
+ expect(await screen.findByRole('link', { name: 'Images' })).toBeInTheDocument()
+ expect(screen.getAllByRole('link', { name: 'Manage Qs' })[0]).toBeInTheDocument()
+ })
+
+ it('keeps the phone menu to one copy of the links', async () => {
+ mount()
+ expect(screen.queryByRole('link', { name: 'Study plans' })).toBeInTheDocument()
+ await userEvent.click(screen.getByLabelText('Menu'))
+ // Opening the burger adds the mobile copy; both exist in the DOM and CSS
+ // shows one, so the count is what proves there is no third list.
+ expect(screen.getAllByRole('link', { name: 'Study plans' })).toHaveLength(2)
+ // One in the bar for a wide screen, one in the burger for a narrow one.
+ expect(screen.getAllByRole('button', { name: 'Logout' })).toHaveLength(2)
+ })
+})
diff --git a/frontend/src/hooks/useHidingBar.js b/frontend/src/hooks/useHidingBar.js
new file mode 100644
index 0000000..4c3734e
--- /dev/null
+++ b/frontend/src/hooks/useHidingBar.js
@@ -0,0 +1,43 @@
+import { useEffect, useRef, useState } from 'react'
+
+/**
+ * Hide something while the reader is scrolling down, bring it back on the way up.
+ *
+ * The section bar is navigation: wanted at the moment you decide to go somewhere
+ * else, in the way for every moment in between. Scrolling up is the gesture that
+ * means "I am looking for something", so that is when it comes back.
+ *
+ * Two guards stop it flickering: nothing hides until you are past `offset`, so a
+ * short page never loses it, and a movement under `threshold` is treated as
+ * noise — trackpads and momentum scrolling emit a lot of one-pixel jitter.
+ */
+export default function useHidingBar({ offset = 90, threshold = 6 } = {}) {
+ const [hidden, setHidden] = useState(false)
+ const lastY = useRef(0)
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ lastY.current = window.scrollY
+ let frame = 0
+
+ const onScroll = () => {
+ if (frame) return
+ frame = window.requestAnimationFrame(() => {
+ frame = 0
+ const y = window.scrollY
+ const delta = y - lastY.current
+ if (Math.abs(delta) < threshold) return
+ lastY.current = y
+ setHidden(y > offset && delta > 0)
+ })
+ }
+
+ window.addEventListener('scroll', onScroll, { passive: true })
+ return () => {
+ window.removeEventListener('scroll', onScroll)
+ if (frame) window.cancelAnimationFrame(frame)
+ }
+ }, [offset, threshold])
+
+ return hidden
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a156b90..f74af73 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -114,12 +114,41 @@ body {
.container { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
/* ── Navbar ─────────────────────────────────────────────────── */
-.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 0 0 0; margin-bottom: 32px; position: sticky; top: 0; z-index: 50; }
+/* Two bars. The primary one — identity, search, account — never moves. The
+ section bar leaves while you scroll down and returns on the way up: it is
+ wanted at the moment you decide to go elsewhere, and in the way in between. */
+.navbar { color: var(--navbar-fg); margin-bottom: 32px; position: sticky; top: 0; z-index: 50; }
+.navbar-primary { background: var(--navbar-bg); }
.navbar .navbar-inner { display: flex; justify-content: space-between; align-items: center; gap: 14px; height: 52px; }
.navbar .logo { font-size: 1.15rem; font-weight: 700; color: #60a5fa; text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; }
+
+.navbar-account { display: flex; align-items: center; gap: 8px; }
+
+.navbar-sections {
+ background: var(--card-bg);
+ border-bottom: 1px solid var(--border);
+ height: 46px;
+ overflow: hidden;
+ transition: height 0.18s ease;
+}
+/* Collapsing the height rather than sliding it away, so the page moves up with
+ it instead of leaving a gap where the bar used to be. */
+.navbar-sections.is-hidden { height: 0; border-bottom-color: transparent; }
+.navbar-sections:focus-within { height: 46px; }
+.navbar-sections-inner { display: flex; align-items: center; gap: 14px; height: 46px; }
+
+.nav-sections { display: flex; align-items: center; gap: 2px; overflow-x: auto; scrollbar-width: none; }
+.nav-sections::-webkit-scrollbar { display: none; }
+.navbar .nav-sections a {
+ color: var(--text-muted); font-size: 0.84rem; padding: 7px 11px;
+ border-radius: 7px; white-space: nowrap;
+}
+.navbar .nav-sections a:hover { background: var(--bg); color: var(--text); opacity: 1 !important; }
+.navbar .nav-sections a.is-current { color: var(--primary); font-weight: 650; background: var(--option-sel-bg); }
+
+@media (prefers-reduced-motion: reduce) { .navbar-sections { transition: none; } }
[data-theme="markdown"] .navbar .logo { color: #d4a96a; }
-.nav-desktop { display: flex; gap: 4px; align-items: center; }
-.nav-mobile-controls { display: none; align-items: center; gap: 8px; }
+.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; }
.navbar a { color: var(--navbar-fg); text-decoration: none; font-size: 0.83rem; padding: 6px 10px; border-radius: 6px; white-space: nowrap; transition: opacity 0.15s, background 0.15s; }
.navbar a:hover { background: rgba(255,255,255,0.09); opacity: 1 !important; }
.navbar button { background: transparent; border: 1px solid rgba(255,255,255,0.2); color: var(--navbar-fg); padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.82rem; white-space: nowrap; font-family: inherit; }
@@ -667,8 +696,13 @@ body {
.card { padding: 18px 16px; }
.navbar .navbar-inner { height: 48px; }
/* Mobile: show hamburger, hide desktop nav */
- .nav-desktop { display: none; }
- .nav-mobile-controls { display: flex; }
+ /* 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-logout { display: none; }
+ .navbar-sections .nav-sections { display: none; }
+ .navbar-sections, .navbar-sections-inner, .navbar-sections:focus-within { height: 42px; }
+ .navbar-sections.is-hidden { height: 0; }
/* Mobile quiz: hide sidebar, show toggle */
.quiz-sidebar { display: none; }
.quiz-nav-toggle { display: inline-flex; }
diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css
index c3a78b1..398ab09 100644
--- a/frontend/src/pages/ArticlesPage.css
+++ b/frontend/src/pages/ArticlesPage.css
@@ -16,8 +16,29 @@
.article-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.article-header-actions { display: flex; gap: 8px; }
.article-layout { display: grid; grid-template-columns: 240px 1fr; gap: 20px; align-items: start; }
-.article-sections { position: sticky; top: 80px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 14px; }
+/* A rail, not a card: the contents of a long article should stay in view for
+ 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;
+ background: var(--card-bg); border: 1px solid var(--border);
+ border-radius: var(--card-radius); padding: 14px;
+}
.article-sections h4 { margin: 0 0 8px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
+/* Collapsing it hands the width to the prose, which is what a long table or a
+ wide image needs and what nothing else on the page can give it. */
+.article-layout.is-railed-off { grid-template-columns: 34px 1fr; }
+.article-layout.is-railed-off .article-sections { padding: 6px; }
+.article-layout.is-railed-off .article-sections h4,
+.article-layout.is-railed-off .article-sections ul { display: none; }
+.article-rail-toggle {
+ display: flex; align-items: center; justify-content: center;
+ width: 100%; min-height: 34px; margin-bottom: 8px; padding: 4px;
+ background: none; border: 1px solid var(--border); border-radius: 8px;
+ color: var(--text-muted); font: inherit; font-size: 1rem; cursor: pointer;
+}
+.article-rail-toggle:hover { border-color: var(--primary); color: var(--primary); }
+.article-layout.is-railed-off .article-rail-toggle { margin-bottom: 0; }
.article-sections ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.section-link { width: 100%; text-align: left; background: none; border: none; border-radius: 6px; padding: 7px 10px; font-size: .85rem; color: var(--text); cursor: pointer; }
.section-link:hover { background: var(--hover, #eef4fb); }
@@ -37,7 +58,9 @@
.article-edit .form-label { margin-top: 6px; }
@media (max-width: 820px) {
.article-layout { grid-template-columns: 1fr; }
- .article-sections { position: static; display: none; }
+ .article-sections { position: static; display: none; max-height: none; }
+ .article-rail-toggle { display: none; }
+ .article-layout.is-railed-off { grid-template-columns: 1fr; }
.article-sections.open { display: block; }
.article-drawer-toggle { display: inline-block; }
.article-content { padding: 16px; }
diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx
index 365a3a6..f427fbd 100644
--- a/frontend/src/pages/ArticlesPage.jsx
+++ b/frontend/src/pages/ArticlesPage.jsx
@@ -208,6 +208,8 @@ export function ArticlePage() {
// you consult, and a wall of prose hides the one heading you came for.
const [openIds, setOpenIds] = useState({})
const [drawerOpen, setDrawerOpen] = useState(false)
+ // Collapsing the contents rail hands its width to the prose.
+ const [railOpen, setRailOpen] = useState(true)
const [questions, setQuestions] = useState([])
const [cards, setCards] = useState([])
const [editing, setEditing] = useState(searchParams.get('edit') === '1')
@@ -470,11 +472,16 @@ export function ArticlePage() {