feat: the deck is the window, the chips behave like links, and looking counts
**The deck fills the window properly now.** The shell was the right height but the site's footer still sat below it, so the page could be scrolled — and scrolling it took the deck's header off the top and put "Dashboard · Reading · Search" under the verdict buttons. The footer is hidden while a deck is open, exactly as it is for the player, and the page no longer scrolls at all. Same on a phone: the box stays, only the padding changes, and the burger stays the site's menu because there is nothing else for it to mean here. **The chips under a correct answer are links like any other.** An article chip now gets the hover card — what the article says, a tab, or the pane beside the answer — instead of navigating out of a session with no way to see first whether it was worth it. A deck chip asks: it names the deck, says that studying it leaves the session, and offers Study now, New tab, or Not now. **Showing an answer counts the card.** The header moves as you work, so Next is plainly the next thing to do. Counted separately from the verdict, because seeing an answer is not knowing it and adding the two together would make the mastered figure a lie — it reads "3 seen · 20% mastered". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
3e03aa78cc
commit
8461abf5bf
5 changed files with 123 additions and 20 deletions
18
frontend/src/components/QuestionReadingLinks.css
Normal file
18
frontend/src/components/QuestionReadingLinks.css
Normal file
|
|
@ -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; }
|
||||
|
|
@ -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 (
|
||||
<span className="qrl-deck" ref={wrap}>
|
||||
<button type="button" className="question-reading-chip is-cards"
|
||||
aria-expanded={asking} onClick={() => setAsking(v => !v)}>
|
||||
<span aria-hidden="true">▦</span>
|
||||
{deck.deck_title}
|
||||
</button>
|
||||
{asking && (
|
||||
<span className="qrl-ask" role="dialog" aria-label={`Study ${deck.deck_title}`}>
|
||||
<strong>{deck.deck_title}</strong>
|
||||
<span className="qrl-ask-note">
|
||||
A deck of cards written against this topic. Studying it leaves the
|
||||
session you are in.
|
||||
</span>
|
||||
<span className="qrl-ask-row">
|
||||
<Link className="btn btn-primary btn-sm" to={`/flashcards/${deck.deck_id}/study`}>
|
||||
Study now
|
||||
</Link>
|
||||
<a className="btn btn-secondary btn-sm" href={`/flashcards/${deck.deck_id}/study`}
|
||||
target="_blank" rel="noopener noreferrer">New tab</a>
|
||||
<button type="button" className="btn btn-secondary btn-sm"
|
||||
onClick={() => setAsking(false)}>Not now</button>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="question-reading-chips" data-testid="question-reading">
|
||||
{/* 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 key={`${link.article_id}-${link.section_id || 'all'}`}
|
||||
className="question-reading-chip"
|
||||
to={link.section_id ? `/articles/${link.article_id}?section=${link.section_id}` : `/articles/${link.article_id}`}>
|
||||
<ArticleLink key={`${link.article_id}-${link.section_id || 'all'}`}
|
||||
slug={String(link.article_id)} sectionId={link.section_id || null}
|
||||
className="question-reading-chip">
|
||||
<span aria-hidden="true">▤</span>
|
||||
{link.section_title || link.title}
|
||||
</Link>
|
||||
</ArticleLink>
|
||||
))}
|
||||
{decks.map(card => (
|
||||
<Link key={`deck-${card.deck_id}`} className="question-reading-chip is-cards"
|
||||
to={`/flashcards/${card.deck_id}/study`}>
|
||||
<span aria-hidden="true">▦</span>
|
||||
{card.deck_title}
|
||||
</Link>
|
||||
<DeckChip key={`deck-${card.deck_id}`} deck={card} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<h2>{deck.title}</h2>
|
||||
<p className="fc-head-meta">
|
||||
{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
|
||||
</p>
|
||||
</div>
|
||||
<div className="fc-head-tools">
|
||||
|
|
@ -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()
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginBottom: 8, textTransform: 'uppercase', fontWeight: 700, letterSpacing: '0.05em' }}>
|
||||
|
|
@ -236,7 +248,7 @@ export default function FlashcardStudyPage() {
|
|||
<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-primary" onClick={reveal}>Show answer</button>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={next} disabled={currentIdx >= total - 1}>Next →</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue