diff --git a/backend/app/services/article_service.py b/backend/app/services/article_service.py
index 4ad4763..8936ce2 100644
--- a/backend/app/services/article_service.py
+++ b/backend/app/services/article_service.py
@@ -81,14 +81,22 @@ def record_slug(db: Session, article: Article) -> None:
def resolve_slug(db: Session, slug: str) -> Article | None:
- """Find an article by its current slug, or by one it used to have."""
- slug = (slug or "").strip().lower()
- if not slug:
+ """Find an article by numeric id, by its current slug, or by an old one.
+
+ Cross-references in prose are written `[[403|urethritis]]` — by id, because
+ an id survives a rename and a slug does not. So whatever resolves a
+ reference has to accept both: looking only at slugs made every one of those
+ links dead, with no preview and a 404 behind it.
+ """
+ reference = (slug or "").strip().lower()
+ if not reference:
return None
- article = db.query(Article).filter(Article.slug == slug).first()
+ if reference.isdigit():
+ return db.get(Article, int(reference))
+ article = db.query(Article).filter(Article.slug == reference).first()
if article:
return article
- historical = db.query(ArticleSlug).filter(ArticleSlug.slug == slug).first()
+ historical = db.query(ArticleSlug).filter(ArticleSlug.slug == reference).first()
return db.get(Article, historical.article_id) if historical else None
diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx
index e40df7f..b920d55 100644
--- a/frontend/src/components/ArticleLink.jsx
+++ b/frontend/src/components/ArticleLink.jsx
@@ -44,7 +44,10 @@ export default function ArticleLink({ slug, children, className = '' }) {
const timer = useRef(null)
const anchor = useRef(null)
const split = useSplitView()
- const href = `/articles/s/${slug}`
+ // A cross-reference written by id addresses the article directly; one
+ // written by slug goes through the slug route, which also resolves the
+ // names an article used to have.
+ const href = /^\d+$/.test(slug) ? `/articles/${slug}` : `/articles/s/${slug}`
useEffect(() => () => clearTimeout(timer.current), [])
diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx
index 02ac6b2..cac5b50 100644
--- a/frontend/src/components/ArticleReader.jsx
+++ b/frontend/src/components/ArticleReader.jsx
@@ -104,6 +104,10 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
: {})
const allOpen = allSections.length > 0 && allSections.every(sec => openIds[sec.id])
+ // One top-level section with nothing under it is the whole view, so it is
+ // shown as prose rather than as a contents page of one entry.
+ const soleSection = topSections.length === 1 && allSections.length === 1 ? topSections[0] : null
+
const renderSection = (sec, depth) => {
const isOpen = !!openIds[sec.id]
const kids = kidsOf(sec.id)
@@ -141,6 +145,8 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
{railOpen ? '‹' : '›'}
{article.title}
+ {/* A contents list of one entry is not a contents list. */}
+ {!soleSection && (
- {topSections.map(sec => renderSection(sec, 0))}
+ {/* A view with one section does not need a heading over it, or a
+ control to collapse the only thing there is. The tab already
+ names it — "Short" then a heading reading "In short" says the
+ same word twice and hides the content behind a chevron. */}
+ {soleSection ? (
+
{(article.references || []).length > 0 && (
diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css
index 8363c2a..706d858 100644
--- a/frontend/src/components/CategoryColumns.css
+++ b/frontend/src/components/CategoryColumns.css
@@ -109,3 +109,8 @@
background: #fef3c7; color: #92400e;
}
.cc-empty { padding: 14px; font-size: 0.83rem; color: var(--text-muted); }
+
+/* Never wider than what contains it: a flex or grid item defaults to
+ min-width:auto, which lets a scroll container size to its content instead of
+ its box — and then it is the page that scrolls, not the strip. */
+.cc-wrap, .cc-columns { max-width: 100%; min-width: 0; }
diff --git a/frontend/src/components/RichText.test.jsx b/frontend/src/components/RichText.test.jsx
index 9f6bbfe..c30b25e 100644
--- a/frontend/src/components/RichText.test.jsx
+++ b/frontend/src/components/RichText.test.jsx
@@ -56,8 +56,11 @@ describe('rendered prose', () => {
it('turns a cross-reference into a preview link when asked', () => {
mount({ value: 'See [[7|Febrile seizures]] for more.', linkArticles: true })
+ // By id, so the link addresses the article directly. It used to build
+ // /articles/s/7 — the slug route — where "7" is not a slug, so every
+ // cross-reference in the corpus was a dead link with no preview.
expect(screen.getByRole('link', { name: 'Febrile seizures' }))
- .toHaveAttribute('href', '/articles/s/7')
+ .toHaveAttribute('href', '/articles/7')
})
})
diff --git a/frontend/src/index.css b/frontend/src/index.css
index eaebf1b..5e0e1fe 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -107,9 +107,15 @@ body {
color: var(--text);
line-height: 1.6;
transition: background 0.25s, color 0.25s;
- overflow-x: hidden;
}
+/* Both, not just body: with `overflow-x: hidden` on body alone the browser can
+ still propagate the overflow to the viewport, and one element a few pixels
+ too wide then scrolls the whole page sideways — which is why the navbar
+ could be found clipped mid-word at its left edge. Anything genuinely wider
+ than the window scrolls inside its own box instead. */
+html, body { overflow-x: hidden; max-width: 100%; }
+
/* ── Layout ─────────────────────────────────────────────────── */
.container { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
@@ -815,7 +821,11 @@ body {
.exam-switcher select {
background: var(--input-bg); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
- padding: 6px 10px; font-size: .8rem; font-weight: 600; max-width: 220px; cursor: pointer;
+ padding: 6px 10px; font-size: .8rem; font-weight: 600; cursor: pointer;
+ /* Truncate with an ellipsis rather than clipping: "Pediatrics Boards (2948)"
+ is wider than the old 220px cap, and a name cut mid-word reads as a
+ rendering fault rather than as a name too long to show. */
+ max-width: min(260px, 32vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.exam-switcher select:hover { border-color: var(--primary); color: var(--primary); }
.exam-switcher select option { color: var(--text); background: var(--card-bg); }
diff --git a/frontend/src/pages/ArticleSplitView.test.jsx b/frontend/src/pages/ArticleSplitView.test.jsx
index b412451..9ea4a35 100644
--- a/frontend/src/pages/ArticleSplitView.test.jsx
+++ b/frontend/src/pages/ArticleSplitView.test.jsx
@@ -78,11 +78,11 @@ describe('reading a cross-reference beside the article', () => {
await openSplit('meningitis')
const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
- // Contents rail and collapsible heading both, the same as the page behind
- // it. A single-section view opens on arrival rather than showing a heading
- // over a blank space.
- expect(within(pane.querySelector('.article-sections')).getByRole('button', { name: 'Signs' })).toBeInTheDocument()
+ // The pane is the same reader as the page behind it — so a view of one
+ // section is shown as prose there too, with no heading repeating the view
+ // and no contents list of a single entry.
expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
+ expect(within(pane).queryByRole('button', { name: 'Signs' })).not.toBeInTheDocument()
})
it('closing it gives the page back its single column', async () => {
diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css
index 32922fe..11ee83e 100644
--- a/frontend/src/pages/ArticlesPage.css
+++ b/frontend/src/pages/ArticlesPage.css
@@ -222,3 +222,7 @@
.asec-controls { gap: 8px; }
.aview { padding: 6px 10px; font-size: 0.79rem; }
}
+
+/* A view that is one section is shown as prose: no heading repeating the tab
+ above it, no chevron hiding the only thing there is to read. */
+.asec-sole { padding-top: 4px; }
diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx
index c5e0989..f96a962 100644
--- a/frontend/src/pages/ArticlesPage.test.jsx
+++ b/frontend/src/pages/ArticlesPage.test.jsx
@@ -83,16 +83,15 @@ describe('topic reading', () => {
expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
- // A view of one section is not a contents page, so it opens: a lone
- // heading over a blank space reads as a view with nothing in it.
- expect(screen.getByRole('heading', { name: /Initial workup/ })).toBeInTheDocument()
+ // A view of one section is the prose, not a contents page of one entry.
+ // The tab already names the view — "Short" over a heading reading "In
+ // short" says the same word twice and hides the only content behind a
+ // chevron.
expect(screen.getByText('Section markdown')).toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: /Initial workup/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /Expand all|Collapse all/ })).not.toBeInTheDocument()
+ expect(document.querySelector('.article-sections .atoc')).toBeNull()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
-
- // The contents rail still marks the section it jumps to.
- const toc = document.querySelector('.article-sections')
- await userEvent.click(within(toc).getByRole('button', { name: 'Initial workup' }))
- expect(within(toc).getByRole('button', { name: 'Initial workup' })).toHaveClass('active')
// Reading a topic offers a test; it never prints the stem, answer or explanation.
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()