diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css
index 135192a..8363c2a 100644
--- a/frontend/src/components/CategoryColumns.css
+++ b/frontend/src/components/CategoryColumns.css
@@ -20,8 +20,30 @@
cursor: pointer;
}
-.cc-columns { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; }
-.cc-column-slot { flex: 0 0 clamp(220px, 32%, 320px); min-width: 0; }
+.cc-columns {
+ display: flex; gap: 14px; align-items: flex-start;
+ overflow-x: auto; scroll-behavior: smooth;
+ /* Padding on a scroll container is not part of its scrollable width in every
+ browser, so the last column used to sit flush against the clipped edge
+ with nothing beyond it to scroll to. The spacer element supplies the room
+ instead, which every browser counts. */
+ scroll-padding-inline-end: 14px;
+ padding-bottom: 4px;
+}
+/* A fixed width, not a percentage. `32%` resolved against the *visible* width,
+ so the columns shrank as more of them opened and the strip never quite
+ admitted how much there was to scroll. */
+.cc-column-slot { flex: 0 0 min(300px, 78vw); min-width: 0; }
+/* Keeps the last column clear of the edge without relying on container padding. */
+.cc-columns::after { content: ''; flex: 0 0 4px; }
+
+/* The strip scrolls; the scrollbar is what says so. Hiding it left no way to
+ reach the far column with a mouse, since a vertical wheel does not move a
+ horizontal overflow. */
+.cc-columns { scrollbar-width: thin; }
+.cc-columns::-webkit-scrollbar { height: 10px; }
+.cc-columns::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
+.cc-columns::-webkit-scrollbar-thumb:hover { background: var(--text-subtle); }
.cc-column {
background: var(--card-bg);
diff --git a/frontend/src/components/CategoryColumns.jsx b/frontend/src/components/CategoryColumns.jsx
index 5785461..10b7e59 100644
--- a/frontend/src/components/CategoryColumns.jsx
+++ b/frontend/src/components/CategoryColumns.jsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import './CategoryColumns.css'
/** A folder of further topics. */
@@ -126,6 +126,19 @@ export default function CategoryColumns({
// Narrow screens show the deepest column only, with a way back up.
const deepest = columns.length - 1
+ // Opening a category adds a column off the right-hand edge. Without this it
+ // is simply not on screen, and nothing says a strip that already fills the
+ // window has more to the right of it.
+ const strip = useRef(null)
+ useEffect(() => {
+ const box = strip.current
+ if (!box) return
+ const last = box.lastElementChild
+ if (typeof last?.scrollIntoView === 'function') {
+ last.scrollIntoView({ block: 'nearest', inline: 'end', behavior: 'smooth' })
+ }
+ }, [columns.length])
+
return (
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index f617ee0..2d39dbf 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
+import ScrollStrip from './ScrollStrip'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ExamSwitcher from './ExamSwitcher'
@@ -178,14 +179,18 @@ export default function Navbar({ onSignIn, onRegister }) {
window.location.reload()} />
-
+
)}
diff --git a/frontend/src/components/ScrollStrip.css b/frontend/src/components/ScrollStrip.css
new file mode 100644
index 0000000..e5007ec
--- /dev/null
+++ b/frontend/src/components/ScrollStrip.css
@@ -0,0 +1,37 @@
+/* A strip that scrolls sideways, with arrows that say so. */
+
+.ss-wrap { position: relative; display: flex; flex: 1; min-width: 0; align-items: center; }
+
+.ss-strip {
+ display: flex; align-items: center; gap: 2px;
+ flex: 1; min-width: 0;
+ overflow-x: auto; overflow-y: hidden;
+ scrollbar-width: none; scroll-behavior: smooth;
+ -webkit-overflow-scrolling: touch;
+}
+.ss-strip::-webkit-scrollbar { display: none; }
+
+/* The arrows are the affordance, so the scrollbar stays hidden here — unlike a
+ content strip, a nav bar with a scrollbar through it looks broken. */
+.ss-arrow {
+ position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
+ width: 26px; height: 30px; padding: 0;
+ display: inline-flex; align-items: center; justify-content: center;
+ font: inherit; font-size: 1.1rem; line-height: 1;
+ color: var(--text-muted); cursor: pointer;
+ background: var(--bg); border: 1px solid var(--border); border-radius: 7px;
+ box-shadow: 0 1px 4px rgba(15, 23, 42, 0.12);
+}
+.ss-arrow:hover { color: var(--primary); border-color: var(--primary); }
+.ss-arrow.is-start { left: -2px; }
+.ss-arrow.is-end { right: -2px; }
+
+/* Room for the arrow to sit over, so it never covers a whole label. */
+.ss-wrap:has(.ss-arrow.is-start) .ss-strip { padding-left: 28px; }
+.ss-wrap:has(.ss-arrow.is-end) .ss-strip { padding-right: 28px; }
+
+@media (max-width: 720px) {
+ /* Touch scrolls directly; the arrows would only take up room. */
+ .ss-arrow { display: none; }
+ .ss-wrap:has(.ss-arrow) .ss-strip { padding-left: 0; padding-right: 0; }
+}
diff --git a/frontend/src/components/ScrollStrip.jsx b/frontend/src/components/ScrollStrip.jsx
new file mode 100644
index 0000000..877de0d
--- /dev/null
+++ b/frontend/src/components/ScrollStrip.jsx
@@ -0,0 +1,91 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import './ScrollStrip.css'
+
+/**
+ * A horizontal strip of things that scrolls, with a way to actually scroll it.
+ *
+ * The section bar hid its scrollbar and faded the right-hand edge, which says
+ * "there is more" and gives you nothing to do about it: a vertical wheel does
+ * not move a horizontal overflow, so with a plain mouse the last few links —
+ * Courses, Settings — were simply unreachable.
+ *
+ * So: arrows at each end, shown only while there is somewhere to go, and the
+ * wheel mapped onto the axis that actually scrolls. The current item is
+ * scrolled into view on arrival, because the page you are on should be visible
+ * in the bar that says where you are.
+ */
+export default function ScrollStrip({ children, className = '', label, step = 220, as: Tag = 'div', role }) {
+ const box = useRef(null)
+ const [edges, setEdges] = useState({ start: false, end: false })
+
+ const measure = useCallback(() => {
+ const el = box.current
+ if (!el) return
+ const max = el.scrollWidth - el.clientWidth
+ // A pixel of slack: sub-pixel layout leaves scrollLeft a hair short of max
+ // and the end arrow would never switch off.
+ setEdges({ start: el.scrollLeft > 1, end: el.scrollLeft < max - 1 })
+ }, [])
+
+ useEffect(() => {
+ const el = box.current
+ if (!el) return undefined
+ measure()
+ el.addEventListener('scroll', measure, { passive: true })
+ window.addEventListener('resize', measure)
+ // Links can arrive after the first paint (a role loading, a count filling in).
+ const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null
+ observer?.observe(el)
+ return () => {
+ el.removeEventListener('scroll', measure)
+ window.removeEventListener('resize', measure)
+ observer?.disconnect()
+ }
+ }, [measure, children])
+
+ // Bring whatever is marked as current into view. Guarded because not every
+ // environment implements it — jsdom does not, and neither did older Safari.
+ useEffect(() => {
+ const current = box.current?.querySelector('[aria-current]')
+ if (typeof current?.scrollIntoView === 'function') {
+ current.scrollIntoView({ block: 'nearest', inline: 'nearest' })
+ }
+ }, [children])
+
+ const nudge = (direction) => {
+ const el = box.current
+ if (typeof el?.scrollBy === 'function') el.scrollBy({ left: direction * step, behavior: 'smooth' })
+ else if (el) el.scrollLeft += direction * step
+ }
+
+ const onWheel = (event) => {
+ const el = box.current
+ if (!el || event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return
+ const max = el.scrollWidth - el.clientWidth
+ if (max <= 0) return
+ // Only swallow the page's scroll while this strip can still move, so
+ // reaching the end hands the wheel back to the page.
+ const next = el.scrollLeft + event.deltaY
+ if (next > 0 && next < max) event.preventDefault()
+ el.scrollLeft = next
+ }
+
+ return (
+
+ {edges.start && (
+
+ )}
+ {/* Rendered as whatever it is replacing, so a section bar stays a
+ )
+}
diff --git a/frontend/src/components/ScrollStrip.test.jsx b/frontend/src/components/ScrollStrip.test.jsx
new file mode 100644
index 0000000..9de80ed
--- /dev/null
+++ b/frontend/src/components/ScrollStrip.test.jsx
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ScrollStrip from './ScrollStrip'
+
+/** jsdom gives every element zero size, so the strip is told how big it is. */
+function sizeStrip({ scrollWidth, clientWidth, scrollLeft = 0 }) {
+ const strip = document.querySelector('.ss-strip')
+ Object.defineProperty(strip, 'scrollWidth', { value: scrollWidth, configurable: true })
+ Object.defineProperty(strip, 'clientWidth', { value: clientWidth, configurable: true })
+ strip.scrollLeft = scrollLeft
+ strip.scrollBy = vi.fn()
+ // The measurement runs off a scroll event, so its state update has to be
+ // flushed before anything is asserted about the arrows.
+ act(() => { strip.dispatchEvent(new Event('scroll')) })
+ return strip
+}
+
+const links = () => Array.from({ length: 12 }, (_, i) => Page {i})
+
+describe('a strip that scrolls sideways', () => {
+ beforeEach(() => { vi.clearAllMocks() })
+
+ it('offers nothing when everything already fits', () => {
+ render({links()})
+ sizeStrip({ scrollWidth: 400, clientWidth: 400 })
+ expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument()
+ expect(screen.queryByLabelText('Scroll left')).not.toBeInTheDocument()
+ })
+
+ it('offers a way to the end when there is more than fits', () => {
+ render({links()})
+ sizeStrip({ scrollWidth: 1200, clientWidth: 600 })
+ // The old bar faded its right edge and hid the scrollbar, which said
+ // "there is more" and gave a mouse no way to get there.
+ expect(screen.getByLabelText('Scroll right')).toBeInTheDocument()
+ expect(screen.queryByLabelText('Scroll left')).not.toBeInTheDocument()
+ })
+
+ it('offers the way back once you have moved', () => {
+ render({links()})
+ sizeStrip({ scrollWidth: 1200, clientWidth: 600, scrollLeft: 300 })
+ expect(screen.getByLabelText('Scroll left')).toBeInTheDocument()
+ expect(screen.getByLabelText('Scroll right')).toBeInTheDocument()
+ })
+
+ it('stops offering the end once you are at it', () => {
+ render({links()})
+ sizeStrip({ scrollWidth: 1200, clientWidth: 600, scrollLeft: 600 })
+ expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument()
+ expect(screen.getByLabelText('Scroll left')).toBeInTheDocument()
+ })
+
+ it('is not fooled by a sub-pixel remainder at the end', () => {
+ render({links()})
+ // scrollLeft lands a hair short of max; the arrow must still switch off.
+ sizeStrip({ scrollWidth: 1200, clientWidth: 600, scrollLeft: 599.6 })
+ expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument()
+ })
+
+ it('moves the strip when an arrow is used', async () => {
+ render({links()})
+ const strip = sizeStrip({ scrollWidth: 1200, clientWidth: 600 })
+ await userEvent.click(screen.getByLabelText('Scroll right'))
+ expect(strip.scrollBy).toHaveBeenCalledWith({ left: 220, behavior: 'smooth' })
+ })
+
+ it('keeps the arrows out of the tab order — they duplicate the links', () => {
+ render({links()})
+ sizeStrip({ scrollWidth: 1200, clientWidth: 600 })
+ expect(screen.getByLabelText('Scroll right')).toHaveAttribute('tabindex', '-1')
+ })
+})
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 3ebb2dd..eaebf1b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -143,13 +143,10 @@ body {
/* The strip scrolls when the links outrun the width; the fade on the right is
what says so, since a hard cut just looks like a broken layout. */
.navbar-sections-inner { position: relative; }
-.nav-sections {
- display: flex; align-items: center; gap: 2px; flex: 1; min-width: 0;
- overflow-x: auto; scrollbar-width: none;
- -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
- mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
-}
-.nav-sections::-webkit-scrollbar { display: none; }
+/* Layout and scrolling belong to ScrollStrip; this only styles the links.
+ The mask fade that used to sit here implied scrollability and gave nothing
+ to act on — the arrows do that job now, and a fade over one of them would
+ only make the arrow look broken. */
.navbar .nav-sections a {
color: var(--text); font-size: 0.84rem; font-weight: 500; opacity: 0.78;
padding: 7px 11px; border-radius: 7px; white-space: nowrap;
@@ -706,7 +703,8 @@ body {
copy of the same links competing for the same thumb. */
.nav-burger { display: flex; }
.nav-logout { display: none; }
- .navbar-sections .nav-sections { display: none; }
+ /* Hide the whole strip, wrapper and arrows, not just the link row. */
+ .navbar-sections .ss-wrap { 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 */