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() {

{deck.title}

{cards.length} cards · {counts.due} due · {counts.unseen} new - {known.size > 0 && ` · ${known.size} answered`} · {progress}% mastered + {seen.size > 0 && ` · ${seen.size} seen`} · {progress}% mastered

@@ -189,7 +200,8 @@ export default function FlashcardStudyPage() { 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) + if (flipped) setFlipped(false) + else reveal() }} >
@@ -236,7 +248,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() +})