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

{article.title}

+ {/* A contents list of one entry is not a contents list. */} + {!soleSection && ( + )} {article.updated_at && ( @@ -194,14 +201,22 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti ))} )} - {allSections.length > 0 && ( + {allSections.length > 0 && !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 ? ( +
+ {soleSection.content} +
+ ) : topSections.map(sec => renderSection(sec, 0))}
{(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()