fix: a link on a card previews, and the deck reads like the player
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 26s
Tests / e2e (push) Failing after 30s

The preview card was there all along and invisible. It was positioned inside
the paragraph it belonged to, and a flashcard is a small box with its overflow
hidden — so the card was drawn and clipped, and the link read as doing nothing.
I had "fixed" that by turning previews off on cards, which was the wrong end of
the problem: the preview is what the link is for.

It is drawn into the body now, placed from the link's own rectangle, so nothing
between the two can clip it. That is a fix everywhere, not only on cards.

Two things behind it:

- **Clicking a link flipped the card back**, because the whole card is a flip
  target and the click bubbled — so the preview opened and the thing it was
  anchored to vanished in the same gesture. Only the empty parts of a card flip
  it now.
- **There was nowhere for "Split view" to go** from a card. The deck now has a
  pane of its own: the article opens beside the card, the deck keeps its place,
  and following a reference out of the article replaces the pane rather than
  losing you.

**The deck is boxed like the session player.** Header at the top, verdict at
the foot, and only the card scrolls between them. 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 down to read the answer and back up to say whether
you knew it. Unflipped, the foot offers Show answer rather than two buttons
about a card you have not seen.

And the four grey pills down the side of every row in Browse are one strip.

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-13 03:05:24 +02:00
parent 6305d82e95
commit 0a2f7d08db
7 changed files with 205 additions and 44 deletions

View file

@ -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); }

View file

@ -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
<Link ref={anchor} to={href} className={`al-link ${className}`} onClick={followLink}>
{children}
</Link>
{open && preview && (
<span className={`al-card${above ? ' is-above' : ''}`} role="tooltip"
{open && preview && createPortal((
/* Drawn into the body, not beside the link.
*
* A card positioned inside the paragraph it belongs to is clipped by
* whatever that paragraph is inside and a flashcard is a small box
* with its overflow hidden, so the preview was there and invisible,
* which reads as a link that does nothing. In the body it cannot be
* clipped by anything, and it is placed from the link's own rectangle.
*/
<span className={`al-card is-floating${above ? ' is-above' : ''}`} role="tooltip"
style={at ? { left: at.left, top: at.top, bottom: at.bottom } : undefined}
onMouseEnter={hideCancel} onMouseLeave={hideSoon}>
<span className="al-card-title">{preview.title}</span>
{preview.excerpt && <span className="al-card-excerpt">{preview.excerpt}</span>}
@ -158,7 +187,7 @@ export default function ArticleLink({ slug, sectionId = null, children, classNam
</a>
</span>
</span>
)}
), document.body)}
</span>
)
}

View file

@ -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.

View file

@ -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 <div className="loading"><div className="spinner" /></div>
if (!deck) return null
const allDone = known.size === cards.length
return (
<div>
/* 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. */
<SplitViewProvider value={splitView}>
<div className={splitSlug ? 'fc-study has-split' : 'fc-study'}>
<div className="fc-study-main">
{/* 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 && (
<>
<div
style={{
background: 'var(--card-bg)', borderRadius: 'var(--card-radius)',
padding: 40, minHeight: 250, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', textAlign: 'center',
cursor: 'pointer', border: `2px solid ${flipped ? 'var(--primary)' : 'var(--border)'}`,
boxShadow: '0 4px 20px rgba(0,0,0,0.08)', transition: 'border-color 0.2s',
marginBottom: 16,
className={`fc-card${flipped ? ' is-flipped' : ''}`}
/* Anything inside the card that is itself clickable a
cross-reference, a figure handles its own click; only the
empty parts of the card flip it. Following a link used to flip
the card back, which closed the very thing being opened. */
onClick={event => {
if (event.target.closest('a, button, .imgfig, .al-wrap')) return
setFlipped(v => !v)
}}
onClick={() => setFlipped(v => !v)}
>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 8, textTransform: 'uppercase', fontWeight: 700, letterSpacing: '0.05em' }}>
{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. */}
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles previewLinks={false} />
<RichText value={flipped ? currentCard.back : currentCard.front} linkArticles />
</p>
{/* 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() {
)}
</div>
{/* Controls */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-secondary" onClick={prev} disabled={currentIdx === 0}>&#8592; Prev</button>
{flipped && (
<>
<button className="btn btn-primary" onClick={markKnown} style={{ background: '#22c55e', borderColor: '#22c55e' }}>Got it &#10003;</button>
<button className="btn btn-secondary" onClick={markReview} style={{ color: '#ef4444', borderColor: '#ef4444' }}>Review again</button>
</>
)}
<button className="btn btn-secondary" onClick={next} disabled={currentIdx >= total - 1}>Next &#8594;</button>
</div>
<div style={{ textAlign: 'center', marginTop: 8, fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Keyboard: Space/Enter=flip, &#8592;&#8594;=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. */}
<div className="fc-foot">
<div className="fc-foot-row">
<button className="btn btn-secondary" onClick={prev} disabled={currentIdx === 0}>&#8592; Prev</button>
{flipped ? (
<>
<button className="btn fc-known" onClick={markKnown}>Got it &#10003;</button>
<button className="btn fc-again" onClick={markReview}>Review again</button>
</>
) : (
<button className="btn btn-primary" onClick={() => setFlipped(true)}>Show answer</button>
)}
<button className="btn btn-secondary" onClick={next} disabled={currentIdx >= total - 1}>Next &#8594;</button>
</div>
<p className="fc-keys">Space or Enter flips · moves · 1 got it · 2 review</p>
</div>
</>
)}
@ -222,6 +242,16 @@ export default function FlashcardStudyPage() {
{!allDone && total === 0 && (
<div className="card"><div className="empty-state">No cards to show. {mode === 'review' ? 'No cards marked for review.' : ''}</div></div>
)}
</div>
{splitSlug && (
<SplitViewProvider value={paneView}>
<ArticleSplitPane slug={splitSlug} depth={splitTrail.length - 1}
onBack={() => setSplitTrail(trail => trail.slice(0, -1))}
onClose={() => setSplitTrail([])} />
</SplitViewProvider>
)}
</div>
</SplitViewProvider>
)
}

View file

@ -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()
})

View file

@ -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%; }
}

View file

@ -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. */}
<RichText value={flipped ? studyCard.back : studyCard.front} linkArticles previewLinks={false} />
<RichText value={flipped ? studyCard.back : studyCard.front} linkArticles />
{studyCard.image_path && (
<div style={{ marginTop: 10 }}>
<ImageFigure src={studyCard.image_path} alt={studyCard.front} />
@ -500,21 +500,24 @@ export default function FlashcardsPage() {
<p style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 4 }}>{card.front}</p>
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}>
{/* 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. */}
<div className="fc-row-actions">
<button className="btn btn-sm btn-secondary" onClick={() => { setStudyCard(card); setFlipped(false) }}>View</button>
{deletingCard === card.id ? (
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-sm btn-danger" onClick={() => deleteCard(card.id)}>Yes</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(null)}>No</button>
</div>
<>
<button className="btn btn-sm btn-danger" onClick={() => deleteCard(card.id)}>Delete it</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(null)}>Keep</button>
</>
) : educator ? (
<>
<button className="btn btn-sm btn-secondary" onClick={() => startEdit(card)}
aria-expanded={editCard?.id === card.id}>Edit</button>
<button className="btn btn-sm btn-secondary" onClick={() => openLinks(card)}
aria-expanded={linkCardId === card.id}>Links</button>
<button className="btn btn-sm btn-secondary" onClick={() => setDeletingCard(card.id)}
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Delete</button>
<button className="btn btn-sm btn-secondary fc-row-delete"
onClick={() => setDeletingCard(card.id)}>Delete</button>
</>
) : null}
</div>