diff --git a/frontend/src/components/ArticleLink.css b/frontend/src/components/ArticleLink.css
index 6dfa1d3..5b65117 100644
--- a/frontend/src/components/ArticleLink.css
+++ b/frontend/src/components/ArticleLink.css
@@ -13,6 +13,9 @@
z-index: 40;
left: 0;
top: calc(100% + 8px);
+ /* `is-floating` is the card drawn into the body: fixed to the window and
+ placed from the link's rectangle, so nothing between the two can clip it.
+ Above everything, because it is summoned deliberately and briefly. */
display: flex;
flex-direction: column;
gap: 6px;
@@ -45,6 +48,8 @@
}
.al-card.is-above::before { top: auto; bottom: -9px; }
.al-card.is-above { top: auto; bottom: calc(100% + 8px); }
+.al-card.is-floating { position: fixed; z-index: 1290; width: min(340px, calc(100vw - 16px)); }
+.al-card.is-floating.is-above { top: auto; }
.al-card-title { font-weight: 700; font-size: 0.92rem; color: var(--text); }
.al-card-excerpt { color: var(--text-muted); }
.al-card-meta { font-size: 0.75rem; color: var(--text-subtle); }
diff --git a/frontend/src/components/ArticleLink.jsx b/frontend/src/components/ArticleLink.jsx
index 7c65814..987c356 100644
--- a/frontend/src/components/ArticleLink.jsx
+++ b/frontend/src/components/ArticleLink.jsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
import { Link } from 'react-router-dom'
import api from '../api/client'
import { useSplitView } from '../context/SplitViewContext'
@@ -13,6 +14,11 @@ const HOVER_DELAY = 350 // Long enough that crossing a link does not summon a
// Long enough to cross the gap between the link and the card's controls, and to
// cover the blur-then-focus gap when Tab moves between them.
const HIDE_DELAY = 260
+//: What the card is, roughly, for deciding which side of the link it fits on
+//: and how far in from the edge it must sit. Matched to ArticleLink.css.
+const CARD_WIDTH = 340
+const CARD_HEIGHT = 220
+const GAP = 8
export function fetchPreview(slug) {
if (!cache.has(slug)) {
@@ -42,6 +48,9 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam
const [preview, setPreview] = useState(null)
const [open, setOpen] = useState(false)
const [above, setAbove] = useState(false)
+ //: Where the card goes, in window coordinates. It is drawn into the body
+ //: rather than beside the link, so it needs to be told.
+ const [at, setAt] = useState(null)
const timer = useRef(null)
const anchor = useRef(null)
const split = useSplitView()
@@ -58,9 +67,20 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam
const reveal = useCallback(async () => {
const data = await fetchPreview(slug)
if (!data) return
- // Flip the card above the link when there is no room beneath it.
const box = anchor.current?.getBoundingClientRect?.()
- if (box) setAbove(window.innerHeight - box.bottom < 220)
+ if (box) {
+ // Flip the card above the link when there is no room beneath it, and
+ // keep it inside the window sideways.
+ const above = window.innerHeight - box.bottom < CARD_HEIGHT
+ setAbove(above)
+ const left = Math.min(Math.max(8, box.left),
+ Math.max(8, window.innerWidth - CARD_WIDTH - 8))
+ setAt({
+ left,
+ top: above ? undefined : box.bottom + GAP,
+ bottom: above ? window.innerHeight - box.top + GAP : undefined,
+ })
+ }
setPreview(data)
setOpen(true)
}, [slug])
@@ -133,8 +153,17 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam
{children}
- {open && preview && (
- {preview.title}
{preview.excerpt && {preview.excerpt}}
@@ -158,7 +187,7 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam
- )}
+ ), document.body)}
)
}
diff --git a/frontend/src/components/RichText.jsx b/frontend/src/components/RichText.jsx
index 3c8feb6..5f0912b 100644
--- a/frontend/src/components/RichText.jsx
+++ b/frontend/src/components/RichText.jsx
@@ -68,13 +68,10 @@ export default function RichText({
attemptId,
className = '',
linkArticles = false,
- // Whether a cross-reference shows a preview card or simply goes.
- //
- // A card is not a page: it is a box a few lines tall, often inside a
- // flipping panel, and a hover card anchored inside one has nowhere to open —
- // it is drawn off the edge or clipped by the box, and the link reads as
- // broken because clicking it appears to do nothing. Where there is no room
- // to preview, the honest behaviour is to take the reader there.
+ // Whether a cross-reference shows a preview card or simply goes. The card is
+ // drawn into the body now, so nothing can clip it and there is no longer a
+ // place where the preview cannot be shown — this stays for a caller that
+ // genuinely wants a plain link.
previewLinks = true,
// Passing a textId turns on the highlight layer: text keeps the source
// offsets that highlights and the read-aloud cursor are stored against.
diff --git a/frontend/src/pages/FlashcardStudyPage.jsx b/frontend/src/pages/FlashcardStudyPage.jsx
index 301eed4..38f71eb 100644
--- a/frontend/src/pages/FlashcardStudyPage.jsx
+++ b/frontend/src/pages/FlashcardStudyPage.jsx
@@ -1,7 +1,9 @@
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../api/client'
import RichText from '../components/RichText'
+import ArticleSplitPane from '../components/ArticleSplitPane'
+import { SplitViewProvider } from '../context/SplitViewContext'
import ImageFigure from '../components/ImageFigure'
export default function FlashcardStudyPage() {
@@ -110,13 +112,25 @@ export default function FlashcardStudyPage() {
return () => window.removeEventListener('keydown', handler)
}, [next, prev, currentCard])
+ const [splitTrail, setSplitTrail] = useState([])
+ const pushSplit = useCallback((slug) => setSplitTrail(
+ trail => (trail.at(-1) === slug ? trail : [...trail, slug])), [])
+ const splitView = useMemo(() => ({ open: pushSplit, inPane: false }), [pushSplit])
+ const paneView = useMemo(() => ({ open: pushSplit, inPane: true }), [pushSplit])
+ const splitSlug = splitTrail.at(-1) || null
+
if (loading) return
+ /* A card can point at the article it came from, so the article can open
+ beside it — reading the paragraph behind a card is the natural next
+ thing to do, and it should not cost you your place in the deck. */
+
+
+
{/* The way out, above the title and in the same words as everywhere
else in the app — not a grey button at the end of a row of controls
that change what you are studying. */}
@@ -167,15 +181,15 @@ export default function FlashcardStudyPage() {
{!allDone && total > 0 && currentCard && (
<>
{flipped ? 'Back' : 'Front'} · Card {currentIdx + 1} of {total}
@@ -185,7 +199,7 @@ export default function FlashcardStudyPage() {
everything else here, so `[[264|respiratory failure]]`,
`==key points==` and a figure all work on a card — which is
most of what "link cards to things" turns out to mean. */}
-
+
{/* The picture on the card. A card could carry one — the column is
there, the editor accepts one, the API returns it — and no view
@@ -202,19 +216,25 @@ export default function FlashcardStudyPage() {
)}
- {/* Controls */}
-
-
- {flipped && (
- <>
-
-
- >
- )}
-
-
-
- Keyboard: Space/Enter=flip, ←→=navigate, 1=got it, 2=review
+ {/* The verdict bar, pinned to the foot of the deck the way the
+ player's is. A card with a picture on it is taller than the
+ window, and the two buttons the whole exercise turns on were
+ below the fold — you scrolled to read, then scrolled back to say
+ whether you knew it. */}
+
+
+
+ {flipped ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
Space or Enter flips · ← → moves · 1 got it · 2 review
>
)}
@@ -222,6 +242,16 @@ export default function FlashcardStudyPage() {
{!allDone && total === 0 && (
No cards to show. {mode === 'review' ? 'No cards marked for review.' : ''}
+
)
}
diff --git a/frontend/src/pages/FlashcardStudyPage.test.jsx b/frontend/src/pages/FlashcardStudyPage.test.jsx
index 55f140b..8f2cb1b 100644
--- a/frontend/src/pages/FlashcardStudyPage.test.jsx
+++ b/frontend/src/pages/FlashcardStudyPage.test.jsx
@@ -69,3 +69,32 @@ it('renders a card face as prose, so a card can carry a link', async () => {
expect(within(back.closest('.rich-text')).getByText('C1 inhibitor').tagName).toBe('STRONG')
expect(screen.getByText('respiratory failure')).toBeInTheDocument()
})
+
+it('previews a link on a card, and opens the article beside the deck', async () => {
+ // A card is a small box with its overflow hidden, so a preview positioned
+ // inside it was invisible and the link read as doing nothing. The card is
+ // drawn into the body now, and there is somewhere for Split view to go.
+ api.get.mockImplementation(url => {
+ if (url === '/articles/preview/264') return Promise.resolve({ data: {
+ id: 264, slug: 'respiratory-failure', title: 'Pediatric Respiratory Failure',
+ excerpt: 'Oxygenation or CO2 clearance that no longer meets demand.',
+ section_count: 18, status: 'published' } })
+ if (url === '/articles/264') return Promise.resolve({ data: {
+ id: 264, title: 'Pediatric Respiratory Failure', sections: [], status: 'published' } })
+ if (url === '/flashcards/3/study') return Promise.resolve({ data: DECK })
+ return Promise.resolve({ data: {} })
+ })
+ mount()
+ await screen.findByText('Hereditary angioedema')
+ await userEvent.click(screen.getByText('Hereditary angioedema'))
+
+ const link = await screen.findByText('respiratory failure')
+ await userEvent.click(link)
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith('/articles/preview/264'))
+ const card = await screen.findByRole('tooltip')
+ expect(within(card).getByRole('link', { name: /new tab/i }))
+ .toHaveAttribute('href', '/articles/264')
+
+ await userEvent.click(within(card).getByRole('button', { name: /split view/i }))
+ expect(await screen.findByRole('region', { name: /Split view/ })).toBeInTheDocument()
+})
diff --git a/frontend/src/pages/FlashcardsPage.css b/frontend/src/pages/FlashcardsPage.css
index 1cff9a5..b4ca40c 100644
--- a/frontend/src/pages/FlashcardsPage.css
+++ b/frontend/src/pages/FlashcardsPage.css
@@ -7,3 +7,71 @@
.fc-edit-hint { margin: 0; font-size: .76rem; line-height: 1.6; color: var(--text-muted); }
.fc-edit-hint code { font-size: .74rem; }
.fc-edit-image { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
+
+/* Studying a deck with an article open beside it. The deck keeps the middle,
+ the article takes the right, and the deck does not reflow as you follow one
+ reference after another. */
+.fc-study.has-split {
+ display: grid; grid-template-columns: minmax(0, 1fr) minmax(340px, 40%);
+ gap: 16px; align-items: start;
+}
+.fc-study.has-split .article-split-pane { position: sticky; top: 12px; max-height: calc(100dvh - 24px); }
+
+@media (max-width: 1100px) {
+ /* Not enough width for both: the article takes the screen, and closing it
+ puts the card back. */
+ .fc-study.has-split { grid-template-columns: minmax(0, 1fr); }
+ .fc-study.has-split .fc-study-main { display: none; }
+}
+
+/* ── Studying a deck ──────────────────────────────────────────────────
+ Boxed, like the session player: the deck's header at the top, the verdict at
+ the foot, and only the card between them scrolls. A card with a picture on
+ it is taller than the window, and the two buttons the whole exercise turns
+ on were below the fold. */
+.fc-study-main { display: flex; flex-direction: column; gap: 12px; min-height: 0; }
+
+.fc-card {
+ flex: 1; min-height: 0; overflow-y: auto;
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: 10px; padding: 32px 28px; text-align: center; cursor: pointer;
+ background: var(--card-bg); border: 2px solid var(--border);
+ border-radius: var(--card-radius); box-shadow: 0 4px 20px rgba(0, 0, 0, .08);
+ transition: border-color .2s;
+}
+.fc-card.is-flipped { border-color: var(--primary); }
+.fc-card .imgfig { margin: 0; }
+
+.fc-foot {
+ position: sticky; bottom: 0; z-index: 2;
+ padding: 10px 0 6px; background: var(--bg);
+ border-top: 1px solid var(--border);
+}
+.fc-foot-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
+.fc-keys { margin: 8px 0 0; text-align: center; font-size: .74rem; color: var(--text-muted); }
+
+/* Named rather than styled inline: green for the one that means "done with
+ this", red-lettered for the one that means "again". */
+.fc-known { background: #22c55e; border-color: #22c55e; color: #fff; }
+.fc-known:hover { background: #16a34a; border-color: #16a34a; }
+.fc-again { color: #ef4444; border-color: #ef4444; background: none; }
+.fc-again:hover { background: #fef2f2; }
+
+@media (min-width: 900px) {
+ /* Tall enough to be worth boxing: the header and the verdict stay put and
+ the card scrolls between them. Below this the page simply scrolls. */
+ .fc-study-main { height: calc(100dvh - 150px); }
+}
+
+/* What you can do to one card in the browse list: one strip, wrapping onto a
+ second line on a narrow screen rather than becoming a column. */
+.fc-row-actions { display: flex; gap: 6px; flex-wrap: wrap; flex-shrink: 0; align-items: flex-start; }
+.fc-row-actions .btn { white-space: nowrap; }
+.fc-row-delete { color: var(--text-muted); }
+.fc-row-delete:hover { color: var(--wrong-fg, #b91c1c); border-color: currentColor; }
+
+@media (max-width: 700px) {
+ /* Under the card rather than beside it: at 390px a strip of four buttons
+ beside the text leaves the stem two words wide. */
+ .fc-row-actions { width: 100%; }
+}
diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx
index 9e7682e..37f55c2 100644
--- a/frontend/src/pages/FlashcardsPage.jsx
+++ b/frontend/src/pages/FlashcardsPage.jsx
@@ -323,7 +323,7 @@ export default function FlashcardsPage() {
{/* Rendered, not printed: a card's faces are prose, so a
cross-reference on one is a link here too rather than a pair
of brackets and a number. */}
-
+
{studyCard.image_path && (
@@ -500,21 +500,24 @@ export default function FlashcardsPage() {
+ {/* One row, not a column of four grey pills down the side of
+ every card. They are all one kind of thing — what you can do
+ to this card — so they read as one control strip. */}
+