diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py
index 912a0fb..796def9 100644
--- a/backend/app/routers/articles.py
+++ b/backend/app/routers/articles.py
@@ -32,6 +32,10 @@ class ArticleSection(BaseModel):
slug: str
title: str
content: str = ""
+ # A section may sit under another one, which is how "ROS questionnaire"
+ # belongs to "Review of systems" rather than standing alongside it. Absent
+ # or null means top level, so every article written before this stays valid.
+ parent_id: str | None = None
class ArticleWrite(BaseModel):
@@ -117,6 +121,21 @@ def _validate_sections(sections: list[ArticleSection]):
ids.add(section.id)
slugs.add(section.slug.strip())
+ # Nesting is one level deep and points backwards: a parent has to be a
+ # section of this article that was already listed, which rules out a cycle
+ # and a sub-section that renders before the heading it belongs to.
+ parents = set()
+ seen: set[str] = set()
+ for section in sections:
+ if section.parent_id is not None:
+ if section.parent_id == section.id or section.parent_id not in seen:
+ raise HTTPException(400, "A sub-section must sit under an earlier section of this article")
+ parents.add(section.parent_id)
+ seen.add(section.id)
+ nested = {s.id for s in sections if s.parent_id is not None}
+ if parents & nested:
+ raise HTTPException(400, "Sub-sections cannot themselves hold sub-sections")
+
def _section_ids(article):
return {section["id"] for section in (article.sections or [])}
@@ -300,6 +319,49 @@ def recently_viewed(
for article, viewed_at in rows]
+def _plain_excerpt(article: Article, limit: int = 260) -> str:
+ """First readable prose in an article, with the markup taken out.
+
+ A preview shows what the link leads to, so it wants the opening sentences —
+ not a heading, a table pipe, or an image tag rendered as ``.
+ """
+ source = article.summary or article.content or ""
+ if not source.strip() and article.sections:
+ source = (article.sections[0] or {}).get("content") or ""
+ text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", source) # images
+ text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) # links keep their words
+ text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M) # headings
+ text = re.sub(r"[*_`>|]|^\s*[-+]\s", " ", text, flags=re.M)
+ text = re.sub(r"\s+", " ", text).strip()
+ return text[:limit].rstrip() + "…" if len(text) > limit else text
+
+
+@router.get("/preview/{slug}")
+def preview_article(
+ slug: str,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """What a cross-reference points at, for a hover card.
+
+ Deliberately small: a link preview that fetched whole articles would pull
+ down the library a paragraph at a time as somebody reads.
+ """
+ article = db.query(Article).filter(Article.slug == slug.lower()).first()
+ if not article:
+ raise HTTPException(404, "Article not found")
+ if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
+ raise HTTPException(404, "Article not found")
+ return {
+ "id": article.id,
+ "slug": article.slug,
+ "title": article.title,
+ "excerpt": _plain_excerpt(article),
+ "section_count": len(article.sections or []),
+ "status": article.status,
+ }
+
+
@router.get("/{article_id}")
def get_article(
article_id: int,
diff --git a/backend/tests/test_articles_cards.py b/backend/tests/test_articles_cards.py
index 65a5484..b64a2fd 100644
--- a/backend/tests/test_articles_cards.py
+++ b/backend/tests/test_articles_cards.py
@@ -203,3 +203,86 @@ class ArticlesCardsTests(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
+
+
+class ArticleReadingTests(unittest.TestCase):
+ """Nested sections and the preview a cross-reference hover shows."""
+
+ def setUp(self):
+ self.bank = fixtures.BuilderTests()
+ self.bank.setUp()
+ self.client = self.bank.client
+ self.client.app.include_router(articles.router, prefix='/articles')
+ self.bank.user = self.bank.mod
+
+ def tearDown(self):
+ self.bank.tearDown()
+
+ def make(self, sections, slug="nested-topic", **extra):
+ payload = {"title": "Nested topic", "slug": slug, "summary": "Summary",
+ "content": "Intro", "sections": sections, **extra}
+ return self.client.post('/articles/', json=payload)
+
+ def section(self, letter, title, parent=None):
+ body = {"id": letter * 32, "slug": f"sec-{letter}", "title": title, "content": f"{title} body"}
+ if parent:
+ body["parent_id"] = parent * 32
+ return body
+
+ def test_a_section_may_sit_under_an_earlier_one(self):
+ response = self.make([self.section('a', 'Review of systems'),
+ self.section('b', 'ROS questionnaire', parent='a')])
+ self.assertEqual(response.status_code, 200, response.text)
+ sections = response.json()['sections']
+ self.assertEqual([s.get('parent_id') for s in sections], [None, 'a' * 32])
+
+ def test_articles_written_before_nesting_stay_valid(self):
+ # No parent_id at all is the shape every existing article has.
+ response = self.make([{"id": "a" * 32, "slug": "s", "title": "S", "content": "B"}])
+ self.assertEqual(response.status_code, 200, response.text)
+ self.assertIsNone(response.json()['sections'][0].get('parent_id'))
+
+ def test_nesting_that_would_not_render_is_refused(self):
+ cases = {
+ "its own parent": [self.section('a', 'A', parent='a')],
+ "a parent that comes later": [self.section('a', 'A', parent='b'), self.section('b', 'B')],
+ "a parent outside the article": [self.section('a', 'A', parent='f')],
+ # Two levels would need a heading level the reading view does not have.
+ "a sub-section of a sub-section": [self.section('a', 'A'),
+ self.section('b', 'B', parent='a'),
+ self.section('c', 'C', parent='b')],
+ }
+ for label, sections in cases.items():
+ with self.subTest(label):
+ self.assertEqual(self.make(sections, slug=f"case-{len(label)}").status_code, 400)
+
+ def test_preview_is_small_and_respects_who_may_read_it(self):
+ article = self.make([self.section('a', 'A')], slug='preview-topic').json()
+
+ # A draft is not previewable by someone who cannot open it, or the hover
+ # card would leak the title of unpublished work.
+ self.bank.user = self.bank.owner
+ self.assertEqual(self.client.get('/articles/preview/preview-topic').status_code, 404)
+
+ self.bank.user = self.bank.mod
+ self.client.post(f"/articles/{article['id']}/publish", json={'published': True})
+ self.bank.user = self.bank.owner
+ preview = self.client.get('/articles/preview/preview-topic').json()
+ self.assertEqual(preview['title'], 'Nested topic')
+ self.assertEqual(preview['section_count'], 1)
+ self.assertEqual(preview['excerpt'], 'Summary')
+ # Deliberately not the whole article: a preview that carried the body
+ # would pull the library down a paragraph at a time as somebody reads.
+ self.assertNotIn('sections', preview)
+ self.assertNotIn('content', preview)
+ self.assertEqual(self.client.get('/articles/preview/no-such-topic').status_code, 404)
+
+ def test_excerpt_reads_as_prose_not_markup(self):
+ self.make([self.section('a', 'A')], slug='marked-up', summary='',
+ content='## Heading\n\n See [the workup](/articles/workup) **now**.')
+ self.client.post('/articles/1/publish', json={'published': True})
+ excerpt = self.client.get('/articles/preview/marked-up').json()['excerpt']
+ self.assertNotIn('!', excerpt)
+ self.assertNotIn('##', excerpt)
+ self.assertNotIn('/uploads/', excerpt)
+ self.assertIn('the workup', excerpt) # a link keeps its words
diff --git a/docs/TODO.md b/docs/TODO.md
index fe7cb6e..b7753d6 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -51,10 +51,21 @@ Updated 2026-09-10.
## Article reading
-- [ ] **Nested sections** — sub-sections under a section, with a breadcrumb
- (`Article › Section`) and per-section collapse.
-- [ ] **References** — numbered list per article, with superscript markers in the
- body linking down to them.
+- [x] **Nested sections and per-section collapse** — done 2026-09-10. A section
+ may sit under an earlier top-level one (`parent_id` on the section JSON),
+ the contents rail lists sub-sections under their parent, and an article
+ opens as headings only, each expanding where it sits. Deep links open the
+ target section and its parent.
+- [x] **Cross-references with previews** — done 2026-09-10. `[[slug]]` or
+ `[[Label|slug]]` in article prose becomes an in-app link that shows title,
+ excerpt and section count on hover, from `GET /articles/preview/{slug}`.
+ One fetch per article per page; no card on touch, where there is no hover.
+- [x] **Library browsed column by column** — done 2026-09-10. The articles page
+ is now the column browser itself: topics and the articles filed under them
+ share a column, separated by icon. Search still answers with a flat list.
+- [ ] **References** — a section titled "References" is pinned last and styled,
+ but the numbered list with superscript markers linking down to it is not
+ built.
- [ ] **Per-section notes and feedback** — a learner's own note attached to a
section, and a feedback channel to the educator.
- [ ] **High-yield / key-exam-info toggles** — mark spans and let the reader show
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index afcc319..cd2afdf 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -101,6 +101,9 @@ function AppRoutes() {
} />
} />
} />
+ {/* Cross-references in article prose address a topic by slug, which
+ outlives a numeric id and is what an educator actually writes. */}
+ } />
} />
} />
} />
diff --git a/frontend/src/components/ArticleLink.css b/frontend/src/components/ArticleLink.css
new file mode 100644
index 0000000..574f375
--- /dev/null
+++ b/frontend/src/components/ArticleLink.css
@@ -0,0 +1,40 @@
+/* Cross-reference links and their hover cards. */
+
+.al-wrap { position: relative; display: inline; }
+.al-link {
+ color: var(--primary);
+ text-decoration: none;
+ border-bottom: 1px solid color-mix(in srgb, var(--primary) 35%, transparent);
+}
+.al-link:hover { border-bottom-color: var(--primary); }
+
+.al-card {
+ position: absolute;
+ z-index: 40;
+ left: 0;
+ top: calc(100% + 8px);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: min(340px, 78vw);
+ padding: 12px 14px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.16);
+ font-size: 0.84rem;
+ line-height: 1.5;
+ text-align: left;
+ /* The card is a hint, not a target: it must never sit between the pointer and
+ the link it describes. */
+ pointer-events: none;
+}
+.al-card.is-above { top: auto; bottom: calc(100% + 8px); }
+.al-card-title { font-weight: 700; font-size: 0.92rem; color: var(--text); }
+.al-card-excerpt { color: var(--text-muted); }
+.al-card-meta { font-size: 0.75rem; color: var(--text-subtle); }
+
+/* No hover on touch, so the card never appears there and the link is just a link. */
+@media (hover: none) {
+ .al-card { display: none; }
+}
diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx
new file mode 100644
index 0000000..d8ee7fd
--- /dev/null
+++ b/frontend/src/components/ArticleLink.jsx
@@ -0,0 +1,80 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api/client'
+import './ArticleLink.css'
+
+// One fetch per article for the life of the page. A reader hovers the same
+// cross-reference several times while deciding whether to follow it, and each
+// one firing a request would make reading cost more than clicking.
+const cache = new Map()
+
+const HOVER_DELAY = 350 // Long enough that crossing a link does not summon a card.
+
+export function fetchPreview(slug) {
+ if (!cache.has(slug)) {
+ cache.set(slug, api.get(`/articles/preview/${slug}`)
+ .then(res => res.data)
+ .catch(() => null))
+ }
+ return cache.get(slug)
+}
+
+/**
+ * A cross-reference to another article, with a preview on hover.
+ *
+ * Following a link to find out whether it was worth following is the thing that
+ * breaks a train of thought, so the preview answers that in place: title, a
+ * couple of sentences, and how much is there.
+ *
+ * Touch has no hover, so on a phone this is just a link — a card that appears on
+ * tap would sit between the finger and the thing it was about to open.
+ */
+/** The numeric id behind a slug, reusing whatever the hover card already fetched. */
+export const resolveArticleId = (slug) => fetchPreview(slug).then(data => data?.id ?? null)
+
+export default function ArticleLink({ slug, children, className = '' }) {
+ const [preview, setPreview] = useState(null)
+ const [open, setOpen] = useState(false)
+ const [above, setAbove] = useState(false)
+ const timer = useRef(null)
+ const anchor = useRef(null)
+
+ useEffect(() => () => clearTimeout(timer.current), [])
+
+ const show = useCallback(() => {
+ clearTimeout(timer.current)
+ timer.current = setTimeout(async () => {
+ const data = await fetchPreview(slug)
+ if (!data) return
+ // Flip the card above the link when there is no room beneath it.
+ const box = anchor.current?.getBoundingClientRect?.()
+ if (box) setAbove(window.innerHeight - box.bottom < 220)
+ setPreview(data)
+ setOpen(true)
+ }, HOVER_DELAY)
+ }, [slug])
+
+ const hide = useCallback(() => {
+ clearTimeout(timer.current)
+ setOpen(false)
+ }, [])
+
+ return (
+
+
+ {children}
+
+ {open && preview && (
+
+ {preview.title}
+ {preview.excerpt && {preview.excerpt}}
+
+ {preview.section_count} section{preview.section_count === 1 ? '' : 's'}
+ {preview.status !== 'published' && ' · draft'}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ArticleLink.test.jsx b/frontend/src/components/ArticleLink.test.jsx
new file mode 100644
index 0000000..e38bbdb
--- /dev/null
+++ b/frontend/src/components/ArticleLink.test.jsx
@@ -0,0 +1,75 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import ArticleLink from './ArticleLink'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
+
+const preview = {
+ id: 7, slug: 'febrile-seizures', title: 'Febrile seizures',
+ excerpt: 'A seizure with fever in a child aged 6 months to 5 years…',
+ section_count: 4, status: 'published',
+}
+
+const mount = (slug = 'febrile-seizures') => render(
+ febrile seizures)
+
+describe('cross-reference previews', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.useRealTimers()
+ api.get.mockResolvedValue({ data: preview })
+ })
+
+ it('links by slug, which outlives a numeric id', () => {
+ mount()
+ expect(screen.getByRole('link', { name: 'febrile seizures' }))
+ .toHaveAttribute('href', '/articles/s/febrile-seizures')
+ })
+
+ it('shows what the link leads to on hover, and takes it away again', async () => {
+ mount()
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+
+ await userEvent.hover(screen.getByRole('link'))
+ const card = await screen.findByRole('tooltip', {}, { timeout: 2000 })
+ expect(card).toHaveTextContent('Febrile seizures')
+ expect(card).toHaveTextContent('A seizure with fever')
+ expect(card).toHaveTextContent('4 sections')
+
+ await userEvent.unhover(screen.getByRole('link'))
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ })
+
+ it('fetches a given article once however often it is hovered', async () => {
+ mount('one-off-topic')
+ const link = screen.getByRole('link')
+ await userEvent.hover(link)
+ await screen.findByRole('tooltip', {}, { timeout: 2000 })
+ await userEvent.unhover(link)
+ await userEvent.hover(link)
+ await screen.findByRole('tooltip', {}, { timeout: 2000 })
+ expect(api.get).toHaveBeenCalledTimes(1)
+ })
+
+ it('crossing a link is not the same as pausing on it', async () => {
+ mount('quickly-passed')
+ await userEvent.hover(screen.getByRole('link'))
+ await userEvent.unhover(screen.getByRole('link'))
+ // The timer is cancelled, so no request goes out and no card appears.
+ await new Promise(resolve => setTimeout(resolve, 450))
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ expect(api.get).not.toHaveBeenCalled()
+ })
+
+ it('says nothing rather than guessing when the article is gone', async () => {
+ api.get.mockRejectedValue({ response: { status: 404 } })
+ mount('deleted-topic')
+ await userEvent.hover(screen.getByRole('link'))
+ await waitFor(() => expect(api.get).toHaveBeenCalled())
+ await new Promise(resolve => setTimeout(resolve, 100))
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css
index d796a3a..135192a 100644
--- a/frontend/src/components/CategoryColumns.css
+++ b/frontend/src/components/CategoryColumns.css
@@ -72,3 +72,18 @@
.cc-columns { overflow-x: visible; }
.cc-column ul { max-height: none; }
}
+
+/* Folder or page — the icon is what separates something you can open from
+ something you can read. */
+.cc-icon { flex-shrink: 0; width: 18px; height: 18px; fill: var(--text-subtle); }
+.cc-icon-article { fill: none; stroke: var(--primary); stroke-width: 1.3; stroke-linejoin: round; }
+.cc-icon-lines { stroke: var(--primary); stroke-width: 1.1; stroke-linecap: round; opacity: 0.75; }
+.cc-row:hover .cc-icon { fill: var(--primary); }
+.cc-row-article:hover .cc-icon-article { fill: none; }
+
+.cc-draft {
+ flex-shrink: 0; font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
+ letter-spacing: 0.05em; padding: 1px 7px; border-radius: 10px;
+ background: #fef3c7; color: #92400e;
+}
+.cc-empty { padding: 14px; font-size: 0.83rem; color: var(--text-muted); }
diff --git a/frontend/src/components/CategoryColumns.jsx b/frontend/src/components/CategoryColumns.jsx
index b55971b..5785461 100644
--- a/frontend/src/components/CategoryColumns.jsx
+++ b/frontend/src/components/CategoryColumns.jsx
@@ -1,18 +1,40 @@
import { useMemo, useState } from 'react'
import './CategoryColumns.css'
+/** A folder of further topics. */
+const FolderIcon = () => (
+
+)
+
+/** A thing you can actually read. */
+const ArticleIcon = () => (
+
+)
+
/**
- * Browse the category tree as side-by-side columns.
+ * Browse the library as side-by-side columns.
*
- * Picking a row in one column opens its children in the next, so the trail you
+ * Picking a row in one column opens its contents in the next, so the trail you
* took stays on screen — you can see where you are and step back a level without
* losing your place. A single dropdown of 780 names shows none of that.
*
+ * A column holds both the topics beneath a category and the articles filed in
+ * it, because to a reader those are the same list: things this heading contains.
+ * Only the icon separates a folder you can open from a page you can read.
+ *
* On a phone there is no room for two columns, so it becomes one column with a
* back button: the same navigation, drawn for the width available, rather than
* two columns squeezed until neither is readable.
*/
-export default function CategoryColumns({ categories, selectedId, onSelect, allLabel = 'All categories' }) {
+export default function CategoryColumns({
+ categories, selectedId, onSelect, allLabel = 'All categories',
+ articlesOf, onOpenArticle, openArticleId,
+}) {
// The chain of opened parents, root first.
const [path, setPath] = useState([])
@@ -37,17 +59,22 @@ export default function CategoryColumns({ categories, selectedId, onSelect, allL
const byId = useMemo(() => Object.fromEntries(categories.map(c => [c.id, c])), [categories])
- // One column per opened level: the roots, then each opened parent's children.
- const columns = [{ parentId: 0, items: childrenOf[0] || [] }]
+ const articlesIn = (categoryId) => (articlesOf ? articlesOf(categoryId) : [])
+
+ // One column per opened level: the roots, then each opened parent's contents.
+ // Anything filed nowhere is listed in the root column rather than being
+ // unreachable because it has no heading to sit under.
+ const columns = [{ parentId: 0, items: childrenOf[0] || [], articles: articlesIn(null) }]
for (const id of path) {
const kids = childrenOf[id] || []
- if (!kids.length) break
- columns.push({ parentId: id, items: kids })
+ const articles = articlesIn(id)
+ if (!kids.length && !articles.length) break
+ columns.push({ parentId: id, items: kids, articles })
}
const openAt = (level, cat) => {
- const next = [...path.slice(0, level), cat.id]
- setPath(childrenOf[cat.id]?.length ? next : path.slice(0, level))
+ const hasContents = (childrenOf[cat.id] || []).length > 0 || articlesIn(cat.id).length > 0
+ setPath(hasContents ? [...path.slice(0, level), cat.id] : path.slice(0, level))
onSelect(cat.id)
}
@@ -57,14 +84,6 @@ export default function CategoryColumns({ categories, selectedId, onSelect, allL
{level === 0 ? allLabel : byId[column.parentId]?.name}
- {level === 0 && (
-
-
-
- )}
{column.items.map(cat => {
const kids = childrenOf[cat.id] || []
const isOpen = path[level] === cat.id
@@ -74,13 +93,32 @@ export default function CategoryColumns({ categories, selectedId, onSelect, allL
className={`cc-row${isOpen ? ' is-open' : ''}${selectedId === cat.id ? ' is-selected' : ''}`}
aria-current={selectedId === cat.id ? 'true' : undefined}
onClick={() => openAt(level, cat)}>
+ {cat.name}{rollup[cat.id] ?? cat.question_count}
- {kids.length > 0 && ›}
+ {(kids.length > 0 || articlesIn(cat.id).length > 0) && (
+ ›
+ )}
)
})}
+ {/* Articles come after the folders: a heading's own reading is the end
+ of the line, and the folders are where there is further to go. */}
+ {column.articles.map(article => (
+
- {/* Columns rather than one long dropdown: the trail you took stays on
- screen, so you can see where you are and step back a level. */}
- {browseOpen && (
-
: query.trim() ? (
+ // Searching is a different question from browsing: you already know the
+ // name, so the answer is the list of matches, not the shelf they sit on.
+ articles.length === 0 ? (
+
No articles yet. Educators add and refine articles gradually.
) : (
-
- {articles.map(article => (
-
-
{article.title}
- {article.summary &&
{article.summary}
}
- {article.sections?.length || 0} sections
-
- ))}
+ /* Columns rather than one long dropdown: the trail you took stays on
+ screen, so you can see where you are and step back a level. */
+