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:
parent
5d59e00144
commit
f94bdddaf5
7 changed files with 369 additions and 101 deletions
|
|
@ -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>
|
||||
|
|
|
|||
56
frontend/src/components/ArticleSaveButton.test.jsx
Normal file
56
frontend/src/components/ArticleSaveButton.test.jsx
Normal 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 topic’s 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue