From 0a2f7d08db4d5fa17f396f7e8aa8539d325a6b6a Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 03:05:24 +0200 Subject: [PATCH] fix: a link on a card previews, and the deck reads like the player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/ArticleLink.css | 5 ++ frontend/src/components/ArticleLink.jsx | 39 ++++++++-- frontend/src/components/RichText.jsx | 11 +-- frontend/src/pages/FlashcardStudyPage.jsx | 78 +++++++++++++------ .../src/pages/FlashcardStudyPage.test.jsx | 29 +++++++ frontend/src/pages/FlashcardsPage.css | 68 ++++++++++++++++ frontend/src/pages/FlashcardsPage.jsx | 19 +++-- 7 files changed, 205 insertions(+), 44 deletions(-) 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
if (!deck) return null const allDone = known.size === cards.length 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 && ( <>
{ + if (event.target.closest('a, button, .imgfig, .al-wrap')) return + setFlipped(v => !v) }} - onClick={() => setFlipped(v => !v)} >
{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.' : ''}
)} +
+ + {splitSlug && ( + + setSplitTrail(trail => trail.slice(0, -1))} + onClose={() => setSplitTrail([])} /> + + )}
+ ) } 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() {

{card.front}

{card.back.slice(0, 120)}{card.back.length > 120 ? '...' : ''}

-
+ {/* 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. */} +
{deletingCard === card.id ? ( -
- - -
+ <> + + + ) : educator ? ( <> - + ) : null}