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' : ''}`} <div className={`article-layout${railOpen ? '' : ' is-railed-off'}${aside ? ' has-aside' : ''}`}
ref={root} style={{ '--article-top': `${headerTop}px` }}> ref={root} style={{ '--article-top': `${headerTop}px` }}>
<aside id={`${idPrefix}article-sections`} className={`article-sections ${drawerOpen ? 'open' : ''}`}> <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-scroll">
<div className="article-rail-head"> <h4>{article.title}</h4>
<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>
{/* A contents list of one entry is not a contents list. */} {/* A contents list of one entry is not a contents list. */}
{!soleSection && ( {!soleSection && (
<ul className="atoc"> <ul className="atoc">
@ -364,7 +363,7 @@ export default function ArticleReader({
and a control with twelve answers to that is a control you and a control with twelve answers to that is a control you
have to think about mid-paragraph. */} have to think about mid-paragraph. */}
<button type="button" className="article-tool" <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])}> 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> <span aria-hidden="true">A</span><span aria-hidden="true" className="article-tool-a">a</span>
</button> </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; } A third column of the reading grid, at a width that never changes. The pane
.article-split.is-open { used to be a nested page a whole reader, contents rail and all, inside a
display: grid; box half the width of the screen and the article kept shuffling right as
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); you followed one reference after another. A fixed column and a rail-less
gap: 16px; reader mean the prose on the left reflows once, when the pane opens, and
align-items: start; stays exactly where it is however deep the trail goes. */
}
/* 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%); }
/* Each pane scrolls alone, which is the whole point: following a .article-layout.has-aside {
cross-reference must not move the article you were reading. */ grid-template-columns: 272px minmax(0, 1fr) clamp(340px, 30vw, 520px);
.article-split.is-open > .article-split-main, }
.article-split.is-open > .article-split-pane { .article-layout.has-aside.is-railed-off {
max-height: calc(100dvh - 120px); grid-template-columns: 30px minmax(0, 1fr) clamp(340px, 30vw, 520px);
overflow-y: auto;
} }
/* 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 { .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); background: var(--card-bg);
border: 1px solid var(--border); border-left: 1px solid var(--border);
border-radius: var(--card-radius);
} }
.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 { .asplit-head {
position: sticky;
top: 0;
z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
flex: none;
padding: 10px 12px; padding: 10px 12px;
background: var(--card-bg); background: var(--bg);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
border-radius: var(--card-radius) var(--card-radius) 0 0;
} }
.asplit-title { .asplit-title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 0.95rem; font-size: 0.92rem;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.asplit-title:hover { color: var(--primary); } .asplit-title:hover { color: var(--primary); }
.asplit-close { .asplit-back, .asplit-close {
flex-shrink: 0; flex-shrink: 0;
width: 30px; width: 28px;
height: 30px; height: 28px;
background: none; background: none;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
font: inherit; font: inherit;
line-height: 1;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
} }
.asplit-close:hover { border-color: var(--primary); color: var(--primary); } .asplit-back:hover, .asplit-close:hover { border-color: var(--primary); color: var(--primary); }
.asplit-body { padding: 4px 12px 16px; } .asplit-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 16px 24px; }
/* The pane is already a card, so the reader inside it does not draw a second one. */ /* Prose, not a page: no rail, no controls, sections already open. */
.asplit-body .article-content { background: none; border: 0; padding: 8px 0 0; } .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) { @media (max-width: 820px) {
/* Two panes on a phone are two unreadable columns, and shrinking one to a /* 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 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, instead, as a sheet over the article which is still exactly where it was,
at the same scroll position, when the sheet closes. */ at the same scroll position, when the sheet closes. */
.article-split.is-open { display: block; } .article-layout.has-aside,
.article-split.is-open > .article-split-main { max-height: none; overflow: visible; } .article-layout.has-aside.is-railed-off { grid-template-columns: minmax(0, 1fr); }
.article-page.is-split { max-width: 1080px; } .article-split-pane {
.article-page.is-split .article-layout { grid-template-columns: 1fr; }
.article-split.is-open > .article-split-pane {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 60; z-index: 60;
max-height: none; max-height: none;
border: 0; border-left: 0;
border-radius: 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. // The article that sent the reader there is still on the page, unmoved.
expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument() expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument()
expect(document.querySelector('.article-split')).toHaveClass('is-open') // The pane is a third column of the reading grid, not a page nested inside
expect(document.querySelector('.article-page')).toHaveClass('is-split') // 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() mount()
await screen.findByText(/Fever alone rarely explains it/) await screen.findByText(/Fever alone rarely explains it/)
await openSplit('meningitis') await openSplit('meningitis')
const pane = await screen.findByRole('region', { name: 'Split view: 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 // It used to render the whole reader contents rail, depth switch,
// section is shown as prose there too, with no heading repeating the view // collapsed sections into a column half the width of the page. There is
// and no contents list of a single entry. // one rail on this screen, and it belongs to the article behind the pane.
expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument() expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
expect(within(pane).queryByRole('button', { name: 'Signs' })).not.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 () => { 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' })) await userEvent.click(within(pane).getByRole('button', { name: 'Close split view' }))
expect(screen.queryByRole('region', { name: /^Split view/ })).not.toBeInTheDocument() expect(screen.queryByRole('region', { name: /^Split view/ })).not.toBeInTheDocument()
expect(document.querySelector('.article-split')).not.toHaveClass('is-open') expect(document.querySelector('.article-layout')).not.toHaveClass('has-aside')
expect(document.querySelector('.article-page')).not.toHaveClass('is-split')
expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument() 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') const card = await screen.findByRole('tooltip')
await userEvent.click(within(card).getByRole('button', { name: /split view/i })) await userEvent.click(within(card).getByRole('button', { name: /split view/i }))
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/articles/9')) 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) expect(document.querySelectorAll('.article-split-pane')).toHaveLength(1)
// The article on the left is where it was; only the pane changed. // The article on the left is where it was; only the pane changed.
expect(screen.getByText(/Fever alone rarely explains it/)).toBeInTheDocument() 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 () => { 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; } .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 { 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-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 /* 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 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 { .article-sections {
position: sticky; top: 76px; max-height: calc(100dvh - 96px); overflow-y: auto; position: sticky;
background: var(--card-bg); border: 1px solid var(--border); top: var(--article-top);
border-radius: var(--card-radius); padding: 14px; 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 /* 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. */ wide image needs and what nothing else on the page can give it. Remembered
.article-layout.is-railed-off { grid-template-columns: 34px 1fr; } between articles, because it is a statement about how you read. */
.article-layout.is-railed-off .article-sections { padding: 6px; } .article-layout.is-railed-off { grid-template-columns: 30px minmax(0, 1fr); }
.article-layout.is-railed-off .article-sections h4, .article-layout.is-railed-off .article-sections { display: none; }
.article-layout.is-railed-off .article-sections ul { display: none; }
.article-rail-toggle { .article-rail-hide, .article-rail-reopen {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
width: 100%; min-height: 34px; margin-bottom: 8px; padding: 4px; width: 26px; height: 26px; padding: 0;
background: none; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 50%;
color: var(--text-muted); font: inherit; font-size: 1rem; cursor: pointer; 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-rail-hide:hover, .article-rail-reopen:hover { border-color: var(--primary); color: var(--primary); }
.article-layout.is-railed-off .article-rail-toggle { margin-bottom: 0; } /* 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; } .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:hover { background: var(--hover, #eef4fb); }
.section-link.active { background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 600; } .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 h2 { margin-top: 0; }
.article-content img { max-width: 100%; border-radius: 8px; } .article-content img { max-width: 100%; border-radius: 8px; }
.article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; } .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-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; } /* Three steps, because a reader wants bigger or smaller and nothing finer. */
.article-drawer-toggle { display: none; margin-bottom: 10px; } .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 { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
.article-linked h3 { margin: 0 0 10px; font-size: 1rem; } .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; } .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 { display: flex; flex-direction: column; gap: 8px; }
.article-edit .form-label { margin-top: 6px; } .article-edit .form-label { margin-top: 6px; }
@media (prefers-reduced-motion: reduce) {
.article-sections { transition: none !important; }
}
@media (max-width: 820px) { @media (max-width: 820px) {
.article-layout { grid-template-columns: 1fr; } /* No room for a rail beside prose. The contents become a panel over the
.article-sections { position: static; display: none; max-height: none; } page, opened from the article's own bar the site menu stays on the
.article-rail-toggle { display: none; } navbar's button, so the two never compete for the same thumb. */
.article-layout.is-railed-off { grid-template-columns: 1fr; } .article-layout, .article-layout.is-railed-off { grid-template-columns: minmax(0, 1fr); }
.article-sections.open { display: block; } .article-sections {
.article-drawer-toggle { display: inline-block; } position: fixed;
.article-content { padding: 16px; } 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-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; } .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: An article is a reference you consult, so it opens as a contents page:
headings only, each expanding where it sits. */ 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-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-heading { margin: 0; font-size: 1rem; }
.asec-head { .asec-head {
@ -178,7 +283,9 @@
} }
.asec-head[aria-expanded='true'] .asec-chevron { transform: rotate(180deg); } .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; } .asec-body > :first-child { margin-top: 0; }
/* A sub-section is drawn inside its parent, indented and quieter, so the /* A sub-section is drawn inside its parent, indented and quieter, so the
@ -200,15 +307,23 @@
.asec-body { font-size: 0.92rem; } .asec-body { font-size: 0.92rem; }
} }
/* One topic, three readings. */ /* One topic, three readings where AMBOSS puts High-yield, at the right of
.asec-controls { display: flex; align-items: center; gap: 10px; justify-content: space-between; flex-wrap: wrap; } 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-switch { display: inline-flex; gap: 2px; padding: 3px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
.aview { .aview {
min-height: 34px; padding: 6px 13px; border: 0; border-radius: 7px; cursor: pointer; 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); background: none; font: inherit; font-size: 0.83rem; font-weight: 600; color: var(--text-muted);
} }
.aview:hover { color: var(--text); } .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. */ /* 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); } .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-title { color: var(--text); font-weight: 600; }
.article-ref-pages { font-variant-numeric: tabular-nums; } .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) { @media (max-width: 640px) {
.asec-controls { gap: 8px; } .asec-controls { gap: 8px; }
.aview { padding: 6px 10px; font-size: 0.79rem; } .aview { padding: 6px 10px; font-size: 0.79rem; }

View file

@ -200,6 +200,10 @@ export function ArticlePage() {
const [showRefine, setShowRefine] = useState(false) const [showRefine, setShowRefine] = useState(false)
const [refineText, setRefineText] = useState('') const [refineText, setRefineText] = useState('')
const navigate = useNavigate() 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(() => { useEffect(() => {
if (idParam) { setId(idParam); return } 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 = ( const notices = (
<> <>
{error && <div className="form-error" role="alert">{error}</div>} {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 gutters: a form field two thousand pixels wide is nobody's idea of an
improvement. */ improvement. */
<div className="article-page is-reading"> <div className="article-page is-reading">
{notices && <div className="article-notices">{notices}</div>} {hasNotices && <div className="article-notices">{notices}</div>}
<SplitViewProvider value={splitView}> <SplitViewProvider value={splitView}>
<ArticleReader article={article} activeSection={activeSection} onOpenSection={openSection} <ArticleReader article={article} activeSection={activeSection} onOpenSection={openSection}
breadcrumbs={breadcrumbs} breadcrumbs={breadcrumbs}

View file

@ -208,6 +208,69 @@ describe('topic reading', () => {
expect(layout).not.toHaveClass('is-railed-off') 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 () => { it('nests a sub-section under its parent and keeps references last', async () => {
const nested = { ...article, sections: [ const nested = { ...article, sections: [
{ id: 'a'.repeat(32), slug: 'ros', title: 'Review of systems', content: 'ROS body' }, { id: 'a'.repeat(32), slug: 'ros', title: 'Review of systems', content: 'ROS body' },