diff --git a/frontend/src/components/QuestionReadingLinks.css b/frontend/src/components/QuestionReadingLinks.css
new file mode 100644
index 0000000..48e1196
--- /dev/null
+++ b/frontend/src/components/QuestionReadingLinks.css
@@ -0,0 +1,18 @@
+/* The "where to read this up" chips under a correct answer, and the question a
+ deck chip asks before it takes you away from the session. */
+
+.qrl-deck { position: relative; display: inline-flex; }
+
+.qrl-ask {
+ position: absolute; z-index: 60;
+ bottom: calc(100% + 8px); left: 0;
+ display: flex; flex-direction: column; gap: 8px;
+ width: min(280px, 78vw); padding: 12px 14px;
+ background: var(--card-bg); color: var(--text);
+ border: 1px solid var(--border); border-radius: 10px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, .16);
+ text-align: left; font-size: .84rem; line-height: 1.5;
+}
+.qrl-ask strong { font-size: .88rem; }
+.qrl-ask-note { color: var(--text-muted); font-size: .78rem; }
+.qrl-ask-row { display: flex; gap: 6px; flex-wrap: wrap; }
diff --git a/frontend/src/components/QuestionReadingLinks.jsx b/frontend/src/components/QuestionReadingLinks.jsx
index d2d2f5f..101b16d 100644
--- a/frontend/src/components/QuestionReadingLinks.jsx
+++ b/frontend/src/components/QuestionReadingLinks.jsx
@@ -1,6 +1,8 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
+import ArticleLink from './ArticleLink'
+import './QuestionReadingLinks.css'
/**
* Where to read up on a question, offered to the learner beside it.
@@ -9,6 +11,58 @@ import api from '../api/client'
* the article's own sections for it — this used to ask for every linked
* article in full, prose and all, to print a title and a heading.
*/
+/**
+ * A deck, offered rather than opened.
+ *
+ * Studying a deck is a different piece of work from the question in front of
+ * you, and a chip that jumped straight into it lost the session — so the chip
+ * asks first, and says how much there is to do before you decide.
+ */
+function DeckChip({ deck }) {
+ const [asking, setAsking] = useState(false)
+ const wrap = useRef(null)
+
+ useEffect(() => {
+ if (!asking) return undefined
+ const away = event => { if (!wrap.current?.contains(event.target)) setAsking(false) }
+ const onKey = event => { if (event.key === 'Escape') setAsking(false) }
+ document.addEventListener('mousedown', away)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('mousedown', away)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [asking])
+
+ return (
+
+
+ {asking && (
+
+ {deck.deck_title}
+
+ A deck of cards written against this topic. Studying it leaves the
+ session you are in.
+
+
+
+ Study now
+
+ New tab
+
+
+
+ )}
+
+ )
+}
+
export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
const [links, setLinks] = useState(null)
//: Cards tied to this question, read from the question's end only. A card
@@ -36,20 +90,20 @@ export default function QuestionReadingLinks({ questionId, variant = 'list' }) {
}
return (
+ {/* The same card a cross-reference in prose gets: what the article
+ says, and the choice of a tab or the pane beside the answer. A chip
+ that navigated took a learner out of the session they were in the
+ middle of, with no way to see first whether it was worth it. */}
{(links || []).map(link => (
-
+ ▤
{link.section_title || link.title}
-
+
))}
{decks.map(card => (
-
- ▦
- {card.deck_title}
-
+
))}
)
diff --git a/frontend/src/pages/FlashcardStudyPage.css b/frontend/src/pages/FlashcardStudyPage.css
index c927192..f210b42 100644
--- a/frontend/src/pages/FlashcardStudyPage.css
+++ b/frontend/src/pages/FlashcardStudyPage.css
@@ -18,6 +18,12 @@
off the bottom of the screen the verdict bar ended up. */
body:has(.fc-study) .navbar { margin-bottom: 0; }
+/* And the site's footer goes, exactly as it does for the player. The shell is
+ the window: leaving a column of links below it meant the page could still be
+ scrolled, and scrolling it took the deck's header off the top and put
+ "Dashboard · Reading · Search" under the verdict buttons. */
+body:has(.fc-study) .site-footer { display: none; }
+
.fc-study {
/* The header and its section strip, and nothing else. */
height: calc(100dvh - 98px);
@@ -84,10 +90,10 @@ body:has(.fc-study) .navbar { margin-bottom: 0; }
}
@media (max-width: 820px) {
- /* No room to box it: the page scrolls, and the verdict sticks to the bottom
- of the window so the two buttons the exercise turns on are always there. */
- .fc-study { height: auto; padding: 0 12px; }
- .fc-card { min-height: 46dvh; padding: 24px 16px; }
- .fc-foot { position: sticky; bottom: 0; background: var(--bg); z-index: 2; }
+ /* Still the window, still no menu of its own — the burger stays the site's,
+ because a deck is not a paper you can leave half-marked and there is
+ nothing else for that button to mean here. Only the padding changes. */
+ .fc-study { padding: 0 12px; }
+ .fc-card { padding: 24px 16px; }
.fc-keys { display: none; }
}
diff --git a/frontend/src/pages/FlashcardStudyPage.jsx b/frontend/src/pages/FlashcardStudyPage.jsx
index b22dbd3..7ec9b48 100644
--- a/frontend/src/pages/FlashcardStudyPage.jsx
+++ b/frontend/src/pages/FlashcardStudyPage.jsx
@@ -16,6 +16,11 @@ export default function FlashcardStudyPage() {
const [currentIdx, setCurrentIdx] = useState(0)
const [flipped, setFlipped] = useState(false)
const [known, setKnown] = useState(new Set())
+ //: Cards whose answer has been looked at. Separate from `known`, which is a
+ //: verdict: seeing the answer is not the same as knowing it, and counting
+ //: the two together would make the mastered figure a lie. It exists so the
+ //: header moves as you work and Next is plainly the next thing to do.
+ const [seen, setSeen] = useState(new Set())
const [review, setReview] = useState(new Set())
const [mode, setMode] = useState('all') // 'all' | 'review'
const [shuffled, setShuffled] = useState(false)
@@ -95,16 +100,22 @@ export default function FlashcardStudyPage() {
const reset = () => {
setKnown(new Set())
+ setSeen(new Set())
setReview(new Set())
setCurrentIdx(0)
setFlipped(false)
setMode('all')
}
+ const reveal = useCallback(() => {
+ setFlipped(true)
+ if (currentCard) setSeen(s => (s.has(currentCard.id) ? s : new Set(s).add(currentCard.id)))
+ }, [currentCard])
+
// Keyboard navigation
useEffect(() => {
const handler = (e) => {
- if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setFlipped(v => !v) }
+ if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); if (flipped) setFlipped(false); else reveal() }
if (e.key === 'ArrowRight') next()
if (e.key === 'ArrowLeft') prev()
if (e.key === '1') markKnown()
@@ -112,7 +123,7 @@ export default function FlashcardStudyPage() {
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
- }, [next, prev, currentCard])
+ }, [next, prev, currentCard, flipped, reveal])
// The window, minus its gutters. Studying a deck is the same kind of work as
// sitting a session: one thing at a time, filling the screen. The site's
@@ -148,7 +159,7 @@ export default function FlashcardStudyPage() {
diff --git a/frontend/src/pages/FlashcardStudyPage.test.jsx b/frontend/src/pages/FlashcardStudyPage.test.jsx
index 8f2cb1b..4772e60 100644
--- a/frontend/src/pages/FlashcardStudyPage.test.jsx
+++ b/frontend/src/pages/FlashcardStudyPage.test.jsx
@@ -98,3 +98,16 @@ it('previews a link on a card, and opens the article beside the deck', async ()
await userEvent.click(within(card).getByRole('button', { name: /split view/i }))
expect(await screen.findByRole('region', { name: /Split view/ })).toBeInTheDocument()
})
+
+it('counts a card as seen the moment its answer is shown', async () => {
+ // Seen is not the same as known — the verdict decides that — but a header
+ // that does not move while you work makes Next look like the wrong button.
+ mount()
+ await screen.findByText('Hereditary angioedema')
+ expect(screen.queryByText(/seen/)).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: /Show answer/ }))
+ expect(await screen.findByText(/1 seen/)).toBeInTheDocument()
+ // And mastered is untouched: looking is not knowing.
+ expect(screen.getByText(/0% mastered/)).toBeInTheDocument()
+})