fix: Reading cannot edit, and the menu button reaches the menu everywhere
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 27s
Tests / e2e (push) Failing after 28s

Reading is read-only. Edit, Unpublish and Generate cards sat in the
reading toolbar, so an educator reading between questions was one
mis-tap from the editor — which is how somebody opened edit mode on
Bronchiolitis in the middle of a session. All three live at
/editorial/articles/:id now, with one named door through from Reading
for whoever may edit. An ?edit=1 on a reading address is not ignored:
the ask is fine, the address is wrong, so it is taken to the editorial
one.

And a cross-reference followed from Editorial stays in Editorial.
Reading and Editorial are two modes of the same page, and one link out
of the second into the first put an educator into the learner's view of
the next article with no way back to the queue.

The menu button now reaches the menu on every page it has been taken
over on. On a phone the burger belongs to whatever is on screen; the
player handed back the site menu as a tab inside its drawer, and an
article and an answer review did not — so on those two the only menu
button on the page could not open the menu. Both have the player's own
two tabs now, from one list of links rather than three copies of it
(one of which pointed Qbank at /questions, which has never been a
route).

And the button shows a cross while what it opens is open. It kept its
three bars behind an open drawer, an inch from the cross inside that
drawer: two controls disagreeing about what was on screen.

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-13 04:36:44 +02:00
parent 734ff194f2
commit e62a742b73
10 changed files with 199 additions and 31 deletions

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import api from '../api/client'
import { useSplitView } from '../context/SplitViewContext'
import './ArticleLink.css'
@ -60,7 +60,12 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam
// A citation can point at one section rather than the whole article; the
// reader opens on it. Nothing in prose writes one, so it is normally absent.
const where = sectionId ? `?section=${encodeURIComponent(sectionId)}` : ''
const href = (/^\d+$/.test(slug) ? `/articles/${slug}` : `/articles/s/${slug}`) + where
// A cross-reference followed from Editorial stays in Editorial. Reading and
// Editorial are two modes of the same page, and one link out of the second
// into the first put an educator into the learner's view of the next
// article with no way back to the queue.
const root = useLocation().pathname.startsWith('/editorial') ? '/editorial/articles' : '/articles'
const href = (/^\d+$/.test(slug) ? `${root}/${slug}` : `${root}/s/${slug}`) + where
useEffect(() => () => clearTimeout(timer.current), [])

View file

@ -7,6 +7,7 @@ import '../pages/ArticlesPage.css'
import useMediaQuery from '../hooks/useMediaQuery'
import useHeaderOffset from '../hooks/useHeaderOffset'
import { useSessionDrawer } from '../context/SessionDrawer'
import SiteMenuLinks from './SiteMenuLinks'
import RichText from './RichText'
/**
* Article prose goes through the same renderer as everything else.
@ -94,6 +95,11 @@ export default function ArticleReader({
// you consult, and a wall of prose hides the one heading you came for.
const [openIds, setOpenIds] = useState({})
const [drawerOpen, setDrawerOpen] = useState(false)
//: Which half of the drawer is showing. The same two the session player
//: offers, because it is the same button: the thing you are in, and the way
//: out of it. Reading had only the contents, so the burger on an article was
//: a menu button that could not reach the menu.
const [drawerTab, setDrawerTab] = useState('contents')
// On a phone the contents live behind the same button as the site menu, the
// way a session's questions do: one control at the top left whose contents
// change with where you are, rather than a second menu to hunt for.
@ -105,13 +111,15 @@ export default function ArticleReader({
// was still sitting there in the layout: the contents did not open, and the
// site menu did not either. The button was simply dead.
const narrow = useMediaQuery('(max-width: 820px)')
const { register: registerDrawer } = useSessionDrawer()
const { register: registerDrawer, setOpen: setDrawerShown } = useSessionDrawer()
useEffect(() => {
if (!narrow || bare) return undefined
// A toggle, not an opener: the button that opened the contents is the
// one a thumb goes back to, and pressing it again did nothing at all.
return registerDrawer(() => setDrawerOpen(open => !open))
}, [narrow, bare, registerDrawer])
// So the header's button can show a cross while this is open.
useEffect(() => { setDrawerShown(drawerOpen) }, [drawerOpen, setDrawerShown])
// Collapsing the contents rail hands its width to the prose.
const [railOpen, setRailOpen] = useState(() => stored(RAIL_KEY, 'open') !== 'closed')
const [size, setSize] = useState(() => (SIZES.includes(stored(SIZE_KEY, 'm')) ? stored(SIZE_KEY, 'm') : 'm'))
@ -381,6 +389,24 @@ export default function ArticleReader({
onClick={() => setDrawerOpen(false)} />
)}
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}>
{/* Only while it is a drawer. On a wide screen this is a rail beside
the prose, and the site menu is in the header where it belongs. */}
{drawerOpen && (
<div className="article-drawer-head">
<button type="button" className="article-drawer-close" aria-label="Close"
onClick={() => setDrawerOpen(false)}></button>
<div className="article-drawer-tabs" role="tablist">
<button type="button" role="tab" aria-selected={drawerTab === 'menu'}
onClick={() => setDrawerTab('menu')}>Main menu</button>
<button type="button" role="tab" aria-selected={drawerTab === 'contents'}
onClick={() => setDrawerTab('contents')}>Contents</button>
</div>
</div>
)}
{drawerOpen && drawerTab === 'menu' && (
<SiteMenuLinks className="article-drawer-menu"
onNavigate={() => setDrawerOpen(false)} />
)}
{/* Outside the scroller, because it hangs over the boundary between the
rail and the prose which is where the reader is looking when they
decide they want the width, and inside it would be clipped. */}
@ -388,7 +414,8 @@ export default function ArticleReader({
aria-label="Collapse contents" onClick={() => setRailOpen(false)}>
<span aria-hidden="true"></span>
</button>
<div className="article-rail-scroll">
<div className="article-rail-scroll"
hidden={drawerOpen && drawerTab === 'menu'}>
<h4>{article.title}</h4>
{/* A contents list of one entry is not a contents list. */}
{!soleSection && (

View file

@ -161,6 +161,10 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
const [peek, setPeek] = useState(false)
// Registered by the quiz player while it is on screen without a rail.
const sessionDrawer = useSessionDrawer()
//: Is the thing this button opens currently open? On a page that has taken
//: the button over, that is the page's drawer; otherwise it is the site
//: menu. Either way the bars fold into a cross.
const shut = sessionDrawer.opener ? sessionDrawer.open : menuOpen
const location = useLocation()
const inSession = useInSession()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
@ -220,25 +224,29 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
It opens the page's own drawer where there is one, because the
site menu is a tab inside that; otherwise the site menu itself. */}
{/* Whichever menu this button currently opens, it shows a cross
while that menu is open. It kept its three bars behind an open
session or contents drawer, an inch from the cross inside the
drawer two controls disagreeing about what was on screen. */}
<button
className="nav-burger"
onClick={() => (sessionDrawer.opener ? sessionDrawer.opener() : setMenuOpen(v => !v))}
aria-label={sessionDrawer.opener ? 'Menu and contents' : 'Menu'}
aria-expanded={sessionDrawer.opener ? undefined : menuOpen}
aria-expanded={sessionDrawer.opener ? sessionDrawer.open : menuOpen}
>
<span style={{
display: 'block', width: 22, height: 2, background: 'currentColor',
borderRadius: 2, transition: 'transform 0.2s, opacity 0.2s',
transform: menuOpen ? 'translateY(7px) rotate(45deg)' : 'none',
transform: shut ? 'translateY(7px) rotate(45deg)' : 'none',
}} />
<span style={{
display: 'block', width: 22, height: 2, background: 'currentColor',
borderRadius: 2, opacity: menuOpen ? 0 : 1, transition: 'opacity 0.2s',
borderRadius: 2, opacity: shut ? 0 : 1, transition: 'opacity 0.2s',
}} />
<span style={{
display: 'block', width: 22, height: 2, background: 'currentColor',
borderRadius: 2, transition: 'transform 0.2s, opacity 0.2s',
transform: menuOpen ? 'translateY(-7px) rotate(-45deg)' : 'none',
transform: shut ? 'translateY(-7px) rotate(-45deg)' : 'none',
}} />
</button>
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}

View file

@ -0,0 +1,27 @@
import { Link } from 'react-router-dom'
/**
* The site's sections, for a drawer that has taken the menu button over.
*
* On a phone the burger belongs to whatever is on screen a session's
* questions, an article's contents and the site menu becomes a tab inside
* that drawer. Three places needed the same list of links, and the first two
* each wrote their own: one of them pointed Qbank at /questions, which has
* never been a route.
*/
const LINKS = [
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/question-bank', 'Qbank'],
['/collections', 'Collections'], ['/ai', 'AI Mode'],
['/study-plans', 'Study plans'], ['/articles', 'Reading'],
['/flashcards', 'Cards'], ['/settings', 'Settings'],
]
export default function SiteMenuLinks({ onNavigate, className = 'quiz-drawer-menu' }) {
return (
<nav className={className} aria-label="Main menu">
{LINKS.map(([to, label]) => (
<Link key={to} to={to} onClick={() => onNavigate?.()}>{label}</Link>
))}
</nav>
)
}

View file

@ -9,13 +9,17 @@ import { createContext, useCallback, useContext, useMemo, useRef, useState } fro
* is the wrong offer so the player takes the button over while it is on
* screen and hands back the site menu as a tab inside its own drawer.
*
* The player registers an opener; the navbar asks whether one exists. Nothing
* else needs to know.
* The player registers an opener; the navbar asks whether one exists. It also
* asks whether the drawer is open, so the button can show a cross rather than
* a second next to the one inside the drawer.
*/
const Context = createContext({ opener: null, register: () => () => {} })
const Context = createContext({
opener: null, open: false, register: () => () => {}, setOpen: () => {},
})
export function SessionDrawerProvider({ children }) {
const [opener, setOpener] = useState(null)
const [open, setOpen] = useState(false)
const current = useRef(null)
const register = useCallback((fn) => {
@ -27,11 +31,14 @@ export function SessionDrawerProvider({ children }) {
if (current.current === fn) {
current.current = null
setOpener(null)
// A page that leaves with its drawer open must not leave the button
// showing a cross for whatever comes next.
setOpen(false)
}
}
}, [])
const value = useMemo(() => ({ opener, register }), [opener, register])
const value = useMemo(() => ({ opener, open, register, setOpen }), [opener, open, register])
return <Context.Provider value={value}>{children}</Context.Provider>
}

View file

@ -151,6 +151,38 @@
transition: transform .18s ease, visibility .18s;
}
.article-sections.open { transform: none; visibility: visible; }
/* The drawer's own head: the way out, and the two things behind this
button. The same shape the session player's drawer has, because it is
the same button on a phone the burger belongs to whatever is on
screen, and the site menu is a tab inside it. Reading had only the
contents, so the menu button on an article could not reach the menu. */
.article-drawer-head {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-bottom: 1px solid var(--border); flex: none;
}
.article-drawer-close {
flex: none; width: 34px; height: 34px;
background: none; border: 0; border-radius: 8px;
font-size: 1rem; cursor: pointer; color: var(--text-muted);
}
.article-drawer-tabs {
display: flex; flex: 1; border: 1px solid var(--border);
border-radius: 8px; overflow: hidden;
}
.article-drawer-tabs button {
flex: 1; padding: 9px 6px; min-height: 40px;
font: inherit; font-size: .84rem; font-weight: 600;
background: var(--card-bg); border: 0; cursor: pointer; color: var(--text-muted);
}
.article-drawer-tabs button[aria-selected="true"] {
background: var(--option-sel-bg); color: var(--primary);
}
.article-drawer-menu { display: flex; flex-direction: column; padding: 6px; overflow-y: auto; }
.article-drawer-menu a {
padding: 12px 10px; min-height: 44px;
font-size: .9rem; text-decoration: none; color: var(--text); border-radius: 8px;
}
.article-drawer-menu a:hover { background: var(--bg); }
/* The dark ground behind an open drawer. Tapping it closes the drawer. */
.article-drawer-backdrop {
/* Below the header, not over it. Covering the header put a transparent

View file

@ -290,7 +290,9 @@ export function ArticlePage() {
// rather than once per section: every section would otherwise fire its own
// request the moment it was expanded.
const [notes, setNotes] = useState([])
const [editing, setEditing] = useState(searchParams.get('edit') === '1')
// Editing is Editorial's, and only Editorial's. An ?edit=1 on a reading
// address does nothing; the effect below sends it where it belongs.
const [editing, setEditing] = useState(searchParams.get('edit') === '1' && inEditorial)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState(null)
const [savedForm, setSavedForm] = useState(null)
@ -308,6 +310,15 @@ export function ArticlePage() {
const [showLinks, setShowLinks] = useState(false)
const [refineText, setRefineText] = useState('')
const navigate = useNavigate()
// A bookmark or an old link that asks to edit from a reading address is
// taken to the editorial one rather than ignored: the ask is legitimate, the
// address is the wrong one.
useEffect(() => {
if (!inEditorial && searchParams.get('edit') === '1' && id) {
navigate(`/editorial/articles/${id}?edit=1`, { replace: true })
}
}, [inEditorial, searchParams, id, navigate])
// Reading claims the window; editing hands it back. Width only the
// navbar's own section strip is left alone, because every link on it is
// somewhere a reader may legitimately want to go mid-article.
@ -460,7 +471,18 @@ export function ArticlePage() {
</nav>
)
const editorActions = (
// Nothing that changes the article appears in Reading not Edit, not
// Unpublish, not Generate cards. Reading is what a learner sees, and an
// educator reading between questions was one mis-tap from the editor: that
// is how somebody ended up in edit mode on Bronchiolitis in the middle of a
// session. The same page at /editorial/articles/:id has all of it.
const editorActions = !inEditorial ? (
canEdit ? (
<Link to={`/editorial/articles/${article.id}`} className="btn btn-secondary btn-sm">
Open in Editorial
</Link>
) : null
) : (
<>
{/* No AI refine here. Reading is where an article is read and, at most,
corrected by hand; a model rewriting a published page underneath its

View file

@ -276,7 +276,10 @@ describe('topic reading', () => {
? Promise.resolve({ data: article })
: Promise.resolve({ data: [] })))
api.patch.mockResolvedValue({ data: { ...article, broken_links: ['[[999|…]]', '[[no-such-topic]]'] } })
render(<MemoryRouter initialEntries={['/articles/1?edit=1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
// Editing happens at Editorial's address; the reading one has no editor.
render(<MemoryRouter initialEntries={['/editorial/articles/1?edit=1']}>
<Routes><Route path="/editorial/articles/:id" element={<ArticlePage />} /></Routes>
</MemoryRouter>)
await userEvent.click(await screen.findByRole('button', { name: 'Save' }))
const notice = await screen.findByRole('alert')
@ -288,6 +291,27 @@ describe('topic reading', () => {
expect(notice).toHaveTextContent('Saved anyway')
})
it('offers nothing that changes the article while you are reading it', async () => {
// An educator reading between questions was one mis-tap from the editor:
// Edit, Unpublish and Generate cards sat in the reading toolbar, and
// somebody opened edit mode on Bronchiolitis in the middle of a session.
// Reading is what a learner sees. One door through to the other mode, and
// it is named.
api.get.mockImplementation(url => (url === '/articles/1'
? Promise.resolve({ data: article })
: Promise.resolve({ data: [] })))
render(<MemoryRouter initialEntries={['/articles/1']}>
<Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes>
</MemoryRouter>)
await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull()
expect(screen.queryByRole('button', { name: /Unpublish|Publish/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Generate cards/ })).toBeNull()
// And a learner is not offered the other mode either the door belongs to
// whoever may edit.
expect(screen.queryByRole('link', { name: 'Open in Editorial' })).toBeNull()
})
it('keeps the contents rail in step with the depth being read', async () => {
const layered = { ...article, sections: [
{ id: 'a'.repeat(32), slug: 'key', title: 'Key points', content: 'Key body', variant: 'short' },

View file

@ -6,6 +6,7 @@ import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
import lazyPage from '../utils/lazyPage'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import RichText from '../components/RichText'
import SiteMenuLinks from '../components/SiteMenuLinks'
import { mergeTextRanges } from '../utils/highlightOffsets'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
@ -350,7 +351,7 @@ export default function QuizPage() {
// Only while there is no rail: on a desktop the list is already beside the
// question, and taking the button over there would hide the site menu for
// no reason.
const { register: registerDrawer } = sessionDrawer
const { register: registerDrawer, setOpen: setDrawerShown } = sessionDrawer
useEffect(() => {
if (hasRail) return undefined
// Toggling, so the button that opened the drawer also shuts it. Read
@ -362,6 +363,8 @@ export default function QuizPage() {
setNavOpen(true)
})
}, [hasRail, registerDrawer])
// So the header's button shows a cross while the drawer has the screen.
useEffect(() => { setDrawerShown(navOpen) }, [navOpen, setDrawerShown])
const [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1)
const [startedAt, setStartedAt] = useState(null)
@ -1637,18 +1640,7 @@ const timerStarted = timeLeft !== null
</div>
</>
) : (
<nav className="quiz-drawer-menu" aria-label="Main menu">
{/* Qbank pointed at /questions, which has never been a route
/questions/:id is the editor. It went nowhere. */}
{[
['/', 'Dashboard'], ['/sessions', 'Sessions'], ['/question-bank', 'Qbank'],
['/collections', 'Collections'], ['/ai', 'AI Mode'],
['/study-plans', 'Study plans'], ['/articles', 'Reading'],
['/flashcards', 'Cards'], ['/settings', 'Settings'],
].map(([to, label]) => (
<Link key={to} to={to} onClick={() => setNavOpen(false)}>{label}</Link>
))}
</nav>
<SiteMenuLinks onNavigate={() => setNavOpen(false)} />
)}
</div>
</div>
@ -2026,7 +2018,15 @@ const timerStarted = timeLeft !== null
const optionFieldKey = `option-${i}`
return (
<div key={i} className="option-row">
<button type="button" aria-pressed={isSelected} aria-disabled={marked}
{/* An answered option with reasoning of its own is not
disabled it is a disclosure, and saying "dimmed"
to a screen reader while the click still opens
something is telling it the opposite of the truth.
The ones with nothing behind them really are inert. */}
<button type="button" aria-pressed={isSelected}
aria-disabled={marked && !current.option_explanations?.[opt]}
aria-expanded={marked && current.option_explanations?.[opt]
? (showAllExplanations || openExplanations.has(i)) : undefined}
className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''} ${isRuledOut(i) ? 'ruled-out' : ''}`}
onClick={() => {
if (hasActiveTextSelection()) return

View file

@ -6,6 +6,7 @@ import { useClaimSessionChrome } from '../context/SessionChrome'
import useMediaQuery from '../hooks/useMediaQuery'
import QuizTools, { LabValues } from '../components/QuizTools'
import FigureStrip from '../components/FigureStrip'
import SiteMenuLinks from '../components/SiteMenuLinks'
import QuestionReadingLinks from '../components/QuestionReadingLinks'
import { optionLetter } from '../utils/options'
import { uploadUrl } from '../utils/uploads'
@ -46,16 +47,22 @@ export default function ResultsPage() {
})
const [responseStats, setResponseStats] = useState(null)
const [navOpen, setNavOpen] = useState(false)
//: The same two halves the player's drawer has: the thing you are in, and
//: the way out of it. Review had only the questions, so the burger here
//: could not reach the site menu.
const [drawerTab, setDrawerTab] = useState('questions')
const hasRail = useMediaQuery('(min-width: 1151px)')
useClaimSessionChrome(true)
// On a phone the site's burger opens this attempt's questions, exactly as it
// does while one is being sat the rail has nowhere to live at that width,
// and a second hamburger for the same list is not an answer.
const { register: registerDrawer } = useSessionDrawer()
const { register: registerDrawer, setOpen: setDrawerShown } = useSessionDrawer()
useEffect(() => {
if (hasRail) return undefined
return registerDrawer(() => setNavOpen(open => !open))
}, [hasRail, registerDrawer])
// So the header's button shows a cross while the drawer has the screen.
useEffect(() => { setDrawerShown(navOpen) }, [navOpen, setDrawerShown])
useEffect(() => {
if (!result) {
@ -121,9 +128,16 @@ export default function ResultsPage() {
<button type="button" className="quiz-drawer-close" aria-label="Close"
onClick={() => setNavOpen(false)}></button>
<div className="quiz-drawer-tabs" role="tablist">
<button type="button" role="tab" aria-selected="true">Questions</button>
<button type="button" role="tab" aria-selected={drawerTab === 'menu'}
onClick={() => setDrawerTab('menu')}>Main menu</button>
<button type="button" role="tab" aria-selected={drawerTab === 'questions'}
onClick={() => setDrawerTab('questions')}>Questions</button>
</div>
</div>
{drawerTab === 'menu' ? (
<SiteMenuLinks onNavigate={() => setNavOpen(false)} />
) : (
<>
<div className="quiz-drawer-title">
<span className="quiz-drawer-badge">Review</span>
<strong>{result.quiz_title || 'This session'}</strong>
@ -142,6 +156,8 @@ export default function ResultsPage() {
</button>
))}
</div>
</>
)}
</div>
</div>
)}