feat: the reading page full width, with split view as a column rather than a page in a page

The article was capped at 1080px and centred, so most of a wide window was
margin while the prose ran as a thin ribbon down the middle — and following a
cross-reference opened a whole second reader, rail and all, inside half of
that. Each further click started further right.

The rail is flush against the left edge of the window now, sticky and full
height with its own scroll, and the article takes the rest. Split view is a
third column of the same grid at a fixed width, so the article reflows once
when the pane opens and never again however deep you go. The pane renders the
reader bare — no rail, no depth switch, sections already open — because a pane
is a column to read, not a page to navigate. It keeps a trail: following a
reference from inside it replaces its contents and offers a way back, since
the only way back before was the browser, which takes the article with it.

The offset the rail starts at is measured off the navbar rather than written
down, because that bar collapses as you scroll and a hard-coded number would
leave a band of page above the rail or hide its first line.

The depth switch moved up beside the sections, where it reads as what it is —
our high-yield — and the rail follows it: a contents list naming sections the
body is not showing is worse than no contents list. A breadcrumb in the sticky
bar tracks the section under your eye, the rail is collapsible and remembers
it, and the toolbar carries text size and save-to-a-library.

And the summary no longer prints `[[288|eczema]]` at the reader. At the head of
an article it is prose in context, so it renders with its references live; in a
card or a list row the card is itself a link, and a link inside a link swallows
the click that was meant to open the article — so there it is flattened to its
labels.

Full-bleed and the session chrome are now separate claims. A reader wants the
window; a reader does not want the section strip taken away, because every link
on it is somewhere they may legitimately go mid-article. That is not true
inside a session.

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-12 08:43:04 +02:00
parent 5d59e00144
commit f94bdddaf5
7 changed files with 369 additions and 101 deletions

View file

@ -309,16 +309,15 @@ export default function ArticleReader({
<div className={`article-layout${railOpen ? '' : ' is-railed-off'}${aside ? ' has-aside' : ''}`}
ref={root} style={{ '--article-top': `${headerTop}px` }}>
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}>
{/* 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. */}
<button type="button" className="article-rail-hide" aria-expanded={railOpen}
aria-label="Collapse contents" onClick={() => setRailOpen(false)}>
<span aria-hidden="true"></span>
</button>
<div className="article-rail-scroll">
<div className="article-rail-head">
<h4>{article.title}</h4>
{/* On the boundary between the rail and the prose, which is where
the reader is looking when they decide they want the width. */}
<button type="button" className="article-rail-hide" aria-expanded={railOpen}
aria-label="Collapse contents" onClick={() => setRailOpen(false)}>
<span aria-hidden="true"></span>
</button>
</div>
<h4>{article.title}</h4>
{/* A contents list of one entry is not a contents list. */}
{!soleSection && (
<ul className="atoc">
@ -364,7 +363,7 @@ export default function ArticleReader({
and a control with twelve answers to that is a control you
have to think about mid-paragraph. */}
<button type="button" className="article-tool"
aria-label={`Text size: ${{ s: 'small', m: 'medium', l: 'large' }[size]}`}
aria-label={`Change text size — currently ${{ s: 'small', m: 'medium', l: 'large' }[size]}`}
onClick={() => setSize(SIZES[(SIZES.indexOf(size) + 1) % SIZES.length])}>
<span aria-hidden="true">A</span><span aria-hidden="true" className="article-tool-a">a</span>
</button>

View file

@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ArticleSaveButton from './ArticleSaveButton'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
const libraries = [{ id: 4, title: 'Neurology' }, { id: 9, title: 'Airway' }]
describe('keeping a topic', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
api.get.mockResolvedValue({ data: libraries })
})
it('files the topics questions into a library and says so afterwards', async () => {
api.put.mockResolvedValue({ data: {} })
render(<ArticleSaveButton articleId={1} questionIds={[11, 12]} />)
await userEvent.click(screen.getByRole('button', { name: 'Save this topic to a library' }))
const panel = await screen.findByRole('dialog', { name: /Save this topic/ })
await userEvent.click(within(panel).getByRole('button', { name: /Neurology/ }))
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/collections/4/questions/11'))
expect(api.put).toHaveBeenCalledWith('/collections/4/questions/12')
// A reader should be able to tell at a glance that this one is already put
// away, without opening the panel to find out.
expect(await screen.findByRole('button', { name: 'Saved to 1 library' })).toBeInTheDocument()
})
it('names a new library and files into it in one press', async () => {
api.post.mockResolvedValue({ data: { id: 21, title: 'Seizures' } })
api.put.mockResolvedValue({ data: {} })
render(<ArticleSaveButton articleId={1} questionIds={[11]} />)
await userEvent.click(screen.getByRole('button', { name: 'Save this topic to a library' }))
await screen.findByRole('dialog', { name: /Save this topic/ })
await userEvent.type(screen.getByLabelText('Create or find a library'), 'Seizures')
await userEvent.click(screen.getByRole('button', { name: /Seizures/ }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/collections/', { title: 'Seizures' }))
expect(api.put).toHaveBeenCalledWith('/collections/21/questions/11')
})
// A library holds questions. Offering the control anyway would file nothing
// and say nothing about why.
it('says plainly when a topic has no questions to file', async () => {
render(<ArticleSaveButton articleId={1} questionIds={[]} />)
await userEvent.click(screen.getByRole('button', { name: 'Save this topic to a library' }))
expect(await screen.findByText(/No questions are linked to this topic yet/)).toBeInTheDocument()
expect(screen.queryByLabelText('Create or find a library')).toBeNull()
})
})

View file

@ -1,89 +1,101 @@
/* Split view: a cross-reference read beside the article that pointed at it. */
/* Split view: a cross-reference read beside the article that pointed at it.
.article-split { min-width: 0; }
.article-split.is-open {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
/* Two columns of prose need more room than one. At the reading page's usual
1080px each pane would be narrower than a table. */
.article-page.is-split { max-width: min(1560px, 100%); }
A third column of the reading grid, at a width that never changes. The pane
used to be a nested page a whole reader, contents rail and all, inside a
box half the width of the screen and the article kept shuffling right as
you followed one reference after another. A fixed column and a rail-less
reader mean the prose on the left reflows once, when the pane opens, and
stays exactly where it is however deep the trail goes. */
/* Each pane scrolls alone, which is the whole point: following a
cross-reference must not move the article you were reading. */
.article-split.is-open > .article-split-main,
.article-split.is-open > .article-split-pane {
max-height: calc(100dvh - 120px);
overflow-y: auto;
.article-layout.has-aside {
grid-template-columns: 272px minmax(0, 1fr) clamp(340px, 30vw, 520px);
}
.article-layout.has-aside.is-railed-off {
grid-template-columns: 30px minmax(0, 1fr) clamp(340px, 30vw, 520px);
}
/* The rail is sticky inside its own pane now, not inside the window. */
.article-page.is-split .article-sections { top: 6px; max-height: calc(100dvh - 200px); }
.article-page.is-split .article-layout { grid-template-columns: 190px 1fr; gap: 14px; }
.article-page.is-split .article-content { padding: 16px 18px; }
.article-split-pane {
position: sticky;
top: var(--article-top);
display: flex;
flex-direction: column;
/* Its own scroll, which is the whole point: following a cross-reference must
not move the article you were reading. */
max-height: calc(100dvh - var(--article-top));
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--card-radius);
border-left: 1px solid var(--border);
}
.article-split-pane:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.article-split-pane:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.asplit-head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
flex: none;
padding: 10px 12px;
background: var(--card-bg);
background: var(--bg);
border-bottom: 1px solid var(--border);
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.asplit-title {
flex: 1;
min-width: 0;
font-size: 0.95rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
text-decoration: none;
overflow-wrap: anywhere;
}
.asplit-title:hover { color: var(--primary); }
.asplit-close {
.asplit-back, .asplit-close {
flex-shrink: 0;
width: 30px;
height: 30px;
width: 28px;
height: 28px;
background: none;
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
}
.asplit-close:hover { border-color: var(--primary); color: var(--primary); }
.asplit-body { padding: 4px 12px 16px; }
/* The pane is already a card, so the reader inside it does not draw a second one. */
.asplit-body .article-content { background: none; border: 0; padding: 8px 0 0; }
.asplit-back:hover, .asplit-close:hover { border-color: var(--primary); color: var(--primary); }
.asplit-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 16px 24px; }
/* Prose, not a page: no rail, no controls, sections already open. */
.article-bare { font-size: 0.92rem; line-height: 1.6; }
.article-bare > .rich-text + .rich-text { margin-top: 0.7em; }
.article-bare .article-summary {
color: var(--text-muted);
border-left: 3px solid var(--primary);
padding-left: 10px;
margin: 0 0 12px;
}
.article-bare .asec { border-bottom: 0; }
.article-bare .asec-heading { margin: 14px 0 2px; }
.article-bare .asec-head { min-height: 0; padding: 2px 0; font-size: 0.95rem; cursor: default; }
.article-bare .asec-head:hover { background: none; }
.article-bare .asec-chevron { display: none; }
.article-bare .asec-body { padding: 0; font-size: 1em; }
@media (max-width: 1200px) {
/* Three columns are tight here. The rail gives up what it can spare rather
than disappearing on its own hiding it would leave the reader with no
handle to bring it back, since the chevron goes with it. */
.article-layout.has-aside { grid-template-columns: 190px minmax(0, 1fr) clamp(300px, 32vw, 420px); }
.article-layout.has-aside.is-railed-off { grid-template-columns: 30px minmax(0, 1fr) clamp(300px, 32vw, 420px); }
}
@media (max-width: 820px) {
/* Two panes on a phone are two unreadable columns, and shrinking one to a
strip only invites pinch-zoom. The cross-reference takes the screen
instead, as a sheet over the article which is still exactly where it was,
at the same scroll position, when the sheet closes. */
.article-split.is-open { display: block; }
.article-split.is-open > .article-split-main { max-height: none; overflow: visible; }
.article-page.is-split { max-width: 1080px; }
.article-page.is-split .article-layout { grid-template-columns: 1fr; }
.article-split.is-open > .article-split-pane {
.article-layout.has-aside,
.article-layout.has-aside.is-railed-off { grid-template-columns: minmax(0, 1fr); }
.article-split-pane {
position: fixed;
inset: 0;
z-index: 60;
max-height: none;
border: 0;
border-radius: 0;
border-left: 0;
}
.asplit-head { border-radius: 0; }
}

View file

@ -68,21 +68,25 @@ describe('reading a cross-reference beside the article', () => {
// The article that sent the reader there is still on the page, unmoved.
expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument()
expect(document.querySelector('.article-split')).toHaveClass('is-open')
expect(document.querySelector('.article-page')).toHaveClass('is-split')
// The pane is a third column of the reading grid, not a page nested inside
// the page which is what the old `.article-split` wrapper made it.
expect(document.querySelector('.article-layout')).toHaveClass('has-aside')
expect(document.querySelector('.article-layout > .article-split-pane')).toBeInTheDocument()
})
it('brings the whole reader with it: contents, and sections that expand', async () => {
it('is prose in the pane, not a second reader with a rail of its own', async () => {
mount()
await screen.findByText(/Fever alone rarely explains it/)
await openSplit('meningitis')
const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
// 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.
// It used to render the whole reader contents rail, depth switch,
// collapsed sections into a column half the width of the page. There is
// one rail on this screen, and it belongs to the article behind the pane.
expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
expect(within(pane).queryByRole('button', { name: 'Signs' })).not.toBeInTheDocument()
expect(pane.querySelector('.article-sections')).toBeNull()
expect(document.querySelectorAll('.article-sections')).toHaveLength(1)
})
it('closing it gives the page back its single column', async () => {
@ -93,8 +97,7 @@ describe('reading a cross-reference beside the article', () => {
await userEvent.click(within(pane).getByRole('button', { name: 'Close split view' }))
expect(screen.queryByRole('region', { name: /^Split view/ })).not.toBeInTheDocument()
expect(document.querySelector('.article-split')).not.toHaveClass('is-open')
expect(document.querySelector('.article-page')).not.toHaveClass('is-split')
expect(document.querySelector('.article-layout')).not.toHaveClass('has-aside')
expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument()
})
@ -109,10 +112,17 @@ describe('reading a cross-reference beside the article', () => {
const card = await screen.findByRole('tooltip')
await userEvent.click(within(card).getByRole('button', { name: /split view/i }))
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/articles/9'))
expect(await screen.findByRole('region', { name: 'Split view: Sepsis' })).toBeInTheDocument()
const deeper = await screen.findByRole('region', { name: 'Split view: Sepsis' })
expect(document.querySelectorAll('.article-split-pane')).toHaveLength(1)
// The article on the left is where it was; only the pane changed.
expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument()
// And the reference that sent you here is one press away, because losing
// your place is the thing split view exists to prevent. There was no way
// back before this but the browser, which takes the article with it.
await userEvent.click(within(deeper).getByRole('button', { name: /Back to the previous/ }))
expect(await screen.findByRole('region', { name: 'Split view: Meningitis' })).toBeInTheDocument()
expect(document.querySelectorAll('.article-split-pane')).toHaveLength(1)
})
it('closes on Escape, the way anything laid over the page should', async () => {

View file

@ -15,55 +15,143 @@
.breadcrumbs a { color: var(--primary); text-decoration: none; }
.article-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.article-header-actions { display: flex; gap: 8px; }
.article-layout { display: grid; grid-template-columns: 240px 1fr; gap: 20px; align-items: start; }
/* The reading layout
Rail flush to the left edge of the window, prose taking everything to the
right of it. `--article-top` is the measured height of the site header, set
by the reader: the navbar's strip collapses as you scroll, and a number
written down here would go stale the moment that changes. */
.app-main.is-bleed { width: 100%; padding: 0; }
/* The navbar keeps 32px of air beneath it for pages that are cards on a
background. A reading page is not one the rail and the article run to the
window edges, and a band of page showing above them reads as a gap where
something failed to load. */
.article-page.is-reading { max-width: none; margin: -32px 0 0; padding: 0; }
.article-notices { max-width: 1080px; margin: 0 auto; padding: 12px 28px 0; }
.article-layout {
--article-top: 98px;
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
align-items: start;
}
/* A rail, not a card: the contents of a long article should stay in view for
its whole length, scrolling on their own when there are more sections than
screen. */
screen. It keeps its own scroll so the page behind it never moves. */
.article-sections {
position: sticky; top: 76px; max-height: calc(100dvh - 96px); overflow-y: auto;
background: var(--card-bg); border: 1px solid var(--border);
border-radius: var(--card-radius); padding: 14px;
position: sticky;
top: var(--article-top);
height: calc(100dvh - var(--article-top));
/* The collapse handle hangs over the boundary, so nothing here may clip. */
overflow: visible;
background: var(--bg);
border-right: 1px solid var(--border);
}
.article-sections h4 { margin: 0 0 8px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); }
.article-rail-scroll { height: 100%; overflow-y: auto; padding: 16px 12px 28px 20px; }
.article-sections h4 { margin: 0 0 10px; font-size: .82rem; font-weight: 700; line-height: 1.35; color: var(--text); }
/* Collapsing it hands the width to the prose, which is what a long table or a
wide image needs and what nothing else on the page can give it. */
.article-layout.is-railed-off { grid-template-columns: 34px 1fr; }
.article-layout.is-railed-off .article-sections { padding: 6px; }
.article-layout.is-railed-off .article-sections h4,
.article-layout.is-railed-off .article-sections ul { display: none; }
.article-rail-toggle {
wide image needs and what nothing else on the page can give it. Remembered
between articles, because it is a statement about how you read. */
.article-layout.is-railed-off { grid-template-columns: 30px minmax(0, 1fr); }
.article-layout.is-railed-off .article-sections { display: none; }
.article-rail-hide, .article-rail-reopen {
display: flex; align-items: center; justify-content: center;
width: 100%; min-height: 34px; margin-bottom: 8px; padding: 4px;
background: none; border: 1px solid var(--border); border-radius: 8px;
color: var(--text-muted); font: inherit; font-size: 1rem; cursor: pointer;
width: 26px; height: 26px; padding: 0;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 50%;
color: var(--text-muted); font: inherit; font-size: 1rem; line-height: 1; cursor: pointer;
}
.article-rail-toggle:hover { border-color: var(--primary); color: var(--primary); }
.article-layout.is-railed-off .article-rail-toggle { margin-bottom: 0; }
.article-rail-hide:hover, .article-rail-reopen:hover { border-color: var(--primary); color: var(--primary); }
/* On the boundary between the rail and the prose where the reader is looking
when they decide they want the width. */
.article-rail-hide { position: absolute; top: 16px; right: -13px; z-index: 3; }
/* And where the rail was, so putting it back is where you last saw it. */
.article-rail-reopen { position: sticky; top: calc(var(--article-top) + 16px); margin: 16px 0 0 2px; }
.article-sections ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.section-link { width: 100%; text-align: left; background: none; border: none; border-radius: 6px; padding: 7px 10px; font-size: .85rem; color: var(--text); cursor: pointer; }
.section-link { width: 100%; text-align: left; background: none; border: none; border-radius: 6px; padding: 7px 10px; font-size: .84rem; line-height: 1.4; color: var(--text); cursor: pointer; }
.section-link:hover { background: var(--hover, #eef4fb); }
.section-link.active { background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 600; }
.article-content { min-width: 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--card-radius); padding: 22px 26px; }
.article-content {
min-width: 0;
background: var(--card-bg);
padding: 14px clamp(20px, 3.5vw, 56px) 48px;
min-height: calc(100dvh - var(--article-top));
}
.article-content h2 { margin-top: 0; }
.article-content img { max-width: 100%; border-radius: 8px; }
.article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.article-content td, .article-content th { border: 1px solid var(--border); padding: 6px 10px; }
.article-updated { font-size: .76rem; color: var(--text-subtle); margin: 0 0 10px; }
.article-summary { font-size: .95rem; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 10px; margin: 0 0 16px; }
.article-drawer-toggle { display: none; margin-bottom: 10px; }
/* Three steps, because a reader wants bigger or smaller and nothing finer. */
.article-text-s { font-size: .92rem; }
.article-text-m { font-size: 1rem; }
.article-text-l { font-size: 1.12rem; }
/* The icon toolbar sits above the title, opposite the trail back to the
library the two things you do with an article that are not reading it. */
.article-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding-top: 10px; }
.article-trail { min-width: 0; }
.article-trail .breadcrumbs { margin-bottom: 0; }
.article-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.article-tool {
display: inline-flex; align-items: baseline; justify-content: center; gap: 1px;
min-width: 32px; height: 32px; padding: 0 8px;
background: none; border: 1px solid var(--border); border-radius: 8px;
font: inherit; font-size: .95rem; line-height: 1; color: var(--text-muted); cursor: pointer;
}
.article-tool:hover { border-color: var(--primary); color: var(--primary); }
.article-tool.is-on { border-color: var(--primary); color: var(--primary); background: var(--primary-soft, #dcebfa); }
.article-tool-a { font-size: .72rem; }
.article-title { margin: 6px 0 0; font-size: 1.75rem; line-height: 1.2; }
.article-updated { font-size: .76rem; color: var(--text-subtle); margin: 8px 0 0; }
.article-summary { font-size: .95em; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 12px; margin: 14px 0 4px; }
.article-summary p:last-child { margin-bottom: 0; }
/* Contents on a narrow screen, where there is no room for a rail beside prose. */
.article-drawer-toggle {
display: none; align-items: center; gap: 6px; height: 32px; padding: 0 12px;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px;
font: inherit; font-size: .82rem; color: var(--text); cursor: pointer;
}
.article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.article-linked h3 { margin: 0 0 10px; font-size: 1rem; }
.linked-card { display: flex; justify-content: space-between; align-items: center; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; flex-wrap: wrap; }
.article-edit { display: flex; flex-direction: column; gap: 8px; }
.article-edit .form-label { margin-top: 6px; }
@media (prefers-reduced-motion: reduce) {
.article-sections { transition: none !important; }
}
@media (max-width: 820px) {
.article-layout { grid-template-columns: 1fr; }
.article-sections { position: static; display: none; max-height: none; }
.article-rail-toggle { display: none; }
.article-layout.is-railed-off { grid-template-columns: 1fr; }
.article-sections.open { display: block; }
.article-drawer-toggle { display: inline-block; }
.article-content { padding: 16px; }
/* No room for a rail beside prose. The contents become a panel over the
page, opened from the article's own bar the site menu stays on the
navbar's button, so the two never compete for the same thumb. */
.article-layout, .article-layout.is-railed-off { grid-template-columns: minmax(0, 1fr); }
.article-sections {
position: fixed;
top: var(--article-top); bottom: 0; left: 0;
z-index: 45;
width: min(320px, 86vw);
height: auto;
transform: translateX(-101%);
/* Hidden rather than merely off-screen: a panel parked past the left edge
is still in the tab order, and tabbing into contents you cannot see is
how a keyboard reader loses the page. */
visibility: hidden;
border-right: 1px solid var(--border);
box-shadow: 8px 0 28px rgba(15, 23, 42, .18);
transition: transform .18s ease, visibility .18s;
}
.article-sections.open { transform: none; visibility: visible; }
.article-rail-hide, .article-rail-reopen { display: none; }
.article-drawer-toggle { display: inline-flex; }
.article-content { padding: 12px 16px 40px; min-height: 0; }
.article-title { font-size: 1.4rem; }
}
.comment-section { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.comment-heading { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
@ -156,10 +244,27 @@
An article is a reference you consult, so it opens as a contents page:
headings only, each expanding where it sits. */
.asec-controls { display: flex; justify-content: flex-end; margin: 18px 0 4px; }
/* The row above the sections. It parks under the site header as you read, so
the depth switch and the expand control stay reachable and the trail can say
where in the article you have got to once the title has scrolled away. */
.asec-controls {
position: sticky;
top: var(--article-top);
z-index: 12;
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
margin: 16px 0 0;
padding: 8px 0;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
}
.asec-controls-left, .asec-controls-right { display: flex; align-items: center; gap: 10px; min-width: 0; }
/* Where you are, once the heading has gone: article, then the section under
the reader's eye the same one the rail marks. */
.asec-where { min-width: 0; font-size: .8rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.asec-where-section { color: var(--text); font-weight: 600; }
.asec-list { border-top: 1px solid var(--border); }
.asec { scroll-margin-top: 84px; border-bottom: 1px solid var(--border); }
.asec { scroll-margin-top: calc(var(--article-top, 98px) + 56px); border-bottom: 1px solid var(--border); }
.asec-heading { margin: 0; font-size: 1rem; }
.asec-head {
@ -178,7 +283,9 @@
}
.asec-head[aria-expanded='true'] .asec-chevron { transform: rotate(180deg); }
.asec-body { padding: 0 8px 18px; font-size: 0.95rem; line-height: 1.65; }
/* `em`, not `rem`: the text-size control sets a size on the article and the
prose inside it has to follow. */
.asec-body { padding: 0 8px 18px; font-size: 0.95em; line-height: 1.65; }
.asec-body > :first-child { margin-top: 0; }
/* A sub-section is drawn inside its parent, indented and quieter, so the
@ -200,15 +307,23 @@
.asec-body { font-size: 0.92rem; }
}
/* One topic, three readings. */
.asec-controls { display: flex; align-items: center; gap: 10px; justify-content: space-between; flex-wrap: wrap; }
/* One topic, three readings where AMBOSS puts High-yield, at the right of
the row over the sections. The rail is filtered by the same choice, so the
contents can never list a section the body is no longer showing. */
.aview-switch { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
.aview {
min-height: 34px; padding: 6px 13px; border: 0; border-radius: 7px; cursor: pointer;
background: none; font: inherit; font-size: 0.83rem; font-weight: 600; color: var(--text-muted);
}
.aview:hover { color: var(--text); }
.aview.is-active { background: var(--card-bg); color: var(--primary); box-shadow: 0 1px 2px rgba(15,23,42,0.08); }
/* Loud on purpose: this is the control that decides how much of the topic is
on screen, and a reader who cannot see which way it is set cannot explain
why two thirds of the contents just went away. */
.aview.is-active {
background: var(--primary); color: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, .22);
}
.aview.is-active:hover { color: #fff; }
/* Sources for the whole article, not markers scattered through the prose. */
.article-references { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--border); }
@ -218,6 +333,11 @@
.article-ref-title { color: var(--text); font-weight: 600; }
.article-ref-pages { font-variant-numeric: tabular-nums; }
@media (max-width: 900px) {
/* Four things in a bar this narrow is three too many; the rail and the
heading already say where you are. */
.asec-where { display: none; }
}
@media (max-width: 640px) {
.asec-controls { gap: 8px; }
.aview { padding: 6px 10px; font-size: 0.79rem; }

View file

@ -200,6 +200,10 @@ export function ArticlePage() {
const [showRefine, setShowRefine] = useState(false)
const [refineText, setRefineText] = useState('')
const navigate = useNavigate()
// 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.
useClaimFullBleed(!editing)
useEffect(() => {
if (idParam) { setId(idParam); return }
@ -332,6 +336,10 @@ export function ArticlePage() {
</>
)
// Moderator business, and rare: kept above the reading rather than folded
// into it, with the page's gutters back so an alert is not flush to the
// window edge.
const hasNotices = !!error || (showRefine && user?.is_moderator)
const notices = (
<>
{error && <div className="form-error" role="alert">{error}</div>}
@ -391,7 +399,7 @@ export function ArticlePage() {
gutters: a form field two thousand pixels wide is nobody's idea of an
improvement. */
<div className="article-page is-reading">
{notices && <div className="article-notices">{notices}</div>}
{hasNotices && <div className="article-notices">{notices}</div>}
<SplitViewProvider value={splitView}>
<ArticleReader article={article} activeSection={activeSection} onOpenSection={openSection}
breadcrumbs={breadcrumbs}

View file

@ -208,6 +208,69 @@ describe('topic reading', () => {
expect(layout).not.toHaveClass('is-railed-off')
})
// 58 summaries in the library contain a cross-reference, and every one of
// them was printed at the reader as `[[288|eczema]]` the syntax an educator
// writes a link in, shown to the person it was written for.
it('renders a summary cross-reference as a link on the article', async () => {
api.get.mockImplementation(url => {
if (url === '/articles/1') return Promise.resolve({ data: { ...article, summary: 'Often confused with [[288|eczema]].', sections: [] } })
return Promise.resolve({ data: [] })
})
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
const summary = await screen.findByText(/Often confused with/)
expect(summary).not.toHaveTextContent('[[288|eczema]]')
expect(within(summary).getByRole('link', { name: 'eczema' })).toHaveAttribute('href', '/articles/288')
})
// The same summary in a list is not the same thing: the card is itself a
// link, so a live cross-reference inside it would swallow the click that was
// meant to open the article. Flattened to its words, never shown raw.
it('flattens a summary cross-reference to its words in a list', async () => {
api.get.mockImplementation(url => {
if (url === '/question-categories/') return Promise.resolve({ data: [] })
if (url === '/articles/') return Promise.resolve({ data: [{ ...article, summary: 'Often confused with [[288|eczema]].' }] })
return Promise.resolve({ data: [] })
})
render(<MemoryRouter initialEntries={['/articles']}><Routes><Route path="/articles" element={<ArticlesPage />} /></Routes></MemoryRouter>)
await screen.findByText('Febrile seizures')
await userEvent.type(screen.getByLabelText('Search articles'), 'febrile')
const card = await screen.findByRole('link', { name: /Febrile seizures/ })
expect(card).toHaveTextContent('Often confused with eczema.')
expect(card).not.toHaveTextContent('[[288|eczema]]')
expect(within(card).queryByRole('link')).toBeNull()
})
// Short is our high-yield. The contents are filtered by the same choice as
// the body, so the rail can never offer a heading the article is no longer
// showing which is the failure worth guarding, not the switch itself.
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' },
{ id: 'b'.repeat(32), slug: 'more', title: 'Worth knowing', content: 'More body', variant: 'short' },
{ id: 'c'.repeat(32), slug: 'patho', title: 'Pathophysiology', content: 'Long body', variant: 'long' },
{ id: 'd'.repeat(32), slug: 'mx', title: 'Management', content: 'Management body', variant: 'long' },
] }
api.get.mockImplementation(url => {
if (url === '/articles/1') return Promise.resolve({ data: layered })
return Promise.resolve({ data: [] })
})
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
await screen.findByText('Introduction markdown')
const toc = () => document.querySelector('.article-sections')
expect(within(toc()).getByRole('button', { name: 'Key points' })).toBeInTheDocument()
expect(within(toc()).queryByRole('button', { name: 'Pathophysiology' })).toBeNull()
expect(screen.getByRole('tab', { name: 'Short' })).toHaveAttribute('aria-selected', 'true')
await userEvent.click(screen.getByRole('tab', { name: 'Long' }))
expect(within(toc()).getByRole('button', { name: 'Pathophysiology' })).toBeInTheDocument()
expect(within(toc()).queryByRole('button', { name: 'Key points' })).toBeNull()
expect(screen.queryByText('Key body')).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Long' })).toHaveAttribute('aria-selected', 'true')
})
it('nests a sub-section under its parent and keeps references last', async () => {
const nested = { ...article, sections: [
{ id: 'a'.repeat(32), slug: 'ros', title: 'Review of systems', content: 'ROS body' },