feat: a deck is studied in the window, the way a session is sat
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 35s
Tests / e2e (push) Failing after 30s

Boxed like the player, and for the same reason: this is one thing at a time,
and a card floating in the middle of an empty page reads as an aside rather
than as work. The deck takes the window, the bar saying what you are studying
and how far in is fixed at the top, the card fills what is left and scrolls
inside its own frame, and the controls are fixed at the foot.

The foot is the player's three-part bar, adapted to what a deck actually has:
the way back to the decks at the left, Prev / Show answer or the verdict /
Next in the middle, the keyboard shortcuts at the right. No Exit session — a
deck has no block to hand in — and no menu, because the controls that change
the run (All, Review, Shuffle, Reset) are at the top where they belong.

The navbar's 32px bottom margin is zeroed while a deck is open, as it is for
the player: the shell is measured against the window minus the header, and
that margin was 32px the arithmetic did not know about — exactly how far off
the bottom of the screen the verdict bar had ended up.

Separately: no "AI draft" and no "New article" on the reading page. Reading is
where an article is read, by everyone including the people who write them.
Writing one starts in Editorial, which is the queue of what still needs a
person, and whose rows already open the editor rather than the reader — so an
administrator reading the library and an administrator working through it are
two different doors, which is the point.

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:30:04 +02:00
parent 2e4e4000a5
commit 3e03aa78cc
3 changed files with 97 additions and 63 deletions

View file

@ -148,12 +148,12 @@ export default function ArticlesPage() {
<h1>Topic reading</h1>
<p className="articles-subtitle">Educator-reviewed articles linked to questions and cards.</p>
</div>
{user?.is_moderator && (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-secondary" onClick={() => { setError(''); setShowAi(v => !v); setShowCreate(false) }}>AI draft</button>
<button className="btn btn-primary" onClick={() => setShowCreate(v => !v)}>New article</button>
</div>
)}
{/* No Draft and no New article here. Reading is where an article is
read, by everyone including the people who write them; writing one
starts in Editorial, which is the queue of what still needs a
person. Both panels still open on this page Editorial sends you
here with ?draft=1 or ?new=1 because this is where the library
they land in lives. */}
</div>
{showAi && user?.is_moderator && (
<div className="card" style={{ marginBottom: 16 }}>

View file

@ -3,62 +3,91 @@
* Its own file, imported by its own page. These rules lived in
* FlashcardsPage.css the deck *list* and each page is its own bundle, so
* opening a deck's study page directly loaded the markup with none of its
* appearance: no card, no box, no pinned verdict bar. It looked right only if
* you happened to arrive via the list.
* appearance. It looked right only if you happened to arrive via the list.
*
* Shaped like the session player, deliberately. Studying a deck is the same
* kind of work as sitting a paper: one thing at a time, filling the window,
* with the state of play fixed at the top and the controls fixed at the foot.
* A deck floating in the middle of an empty page reads as an aside; this reads
* as work.
*/
/* 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; }
/* The navbar carries 32px of margin under it everywhere else on the site, and
the shell below is measured against the window minus the header so that
margin was 32px the arithmetic did not know about, which is exactly how far
off the bottom of the screen the verdict bar ended up. */
body:has(.fc-study) .navbar { margin-bottom: 0; }
.fc-study {
/* The header and its section strip, and nothing else. */
height: calc(100dvh - 98px);
display: flex; flex-direction: column;
padding: 0 24px;
}
.fc-study-main {
flex: 1; min-height: 0;
display: flex; flex-direction: column; gap: 12px;
}
/* The deck's own bar: what you are studying and how far in. Fixed, like the
player's the counter leaving the screen exactly when a long card makes you
want it is the thing this prevents. */
.fc-head { flex: none; padding: 14px 0 10px; border-bottom: 1px solid var(--border); }
.fc-head-row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap;
}
.fc-head h2 { margin: 0 0 2px; font-size: 1.05rem; }
.fc-head-meta { margin: 0; font-size: .82rem; color: var(--text-muted); }
.fc-head-tools { display: flex; gap: 6px; flex-wrap: wrap; }
.fc-head .progress-bar { margin-top: 10px; height: 4px; }
/* The card takes what is left. It scrolls inside its own frame rather than
pushing the verdict off the screen, which is what a radiograph did. */
.fc-card {
/* Shrinks to the card, up to the height it has. A one-line front in a box
four hundred pixels tall is mostly emptiness; a card with a radiograph on
it scrolls inside its own frame rather than pushing the verdict buttons
off the screen. */
flex: 0 1 auto; min-height: 180px; overflow-y: auto;
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;
gap: 12px; padding: 40px 32px; 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);
border-radius: var(--card-radius); box-shadow: 0 4px 20px rgba(0, 0, 0, .06);
transition: border-color .2s;
}
.fc-card.is-flipped { border-color: var(--primary); }
.fc-card .imgfig { margin: 0; }
.fc-face { max-width: 62ch; }
/* The three things, in the player's own shape: the way out at the left, the
navigation in the middle, the verdict beside it. Nothing here ends anything
a deck has no block to hand in so the left is the way back to the decks
rather than an Exit session. */
.fc-foot {
position: sticky; bottom: 0; z-index: 2;
padding: 10px 0 6px; background: var(--bg);
flex: none;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 0 calc(10px + env(safe-area-inset-bottom));
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); }
.fc-foot-nav { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
.fc-keys { flex: none; margin: 0; font-size: .74rem; color: var(--text-subtle); }
/* 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. `max-height` rather than `height`, so a
short card is short the box is a ceiling, not a shape to fill. */
.fc-study-main { max-height: calc(100dvh - 150px); }
/* Reading an article beside the deck. The deck keeps the middle and does not
reflow as one reference is followed after another. */
.fc-study.has-split .fc-study-main { display: none; }
@media (min-width: 1100px) {
.fc-study.has-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 40%); gap: 16px; }
.fc-study.has-split .fc-study-main { display: flex; }
.fc-study.has-split .article-split-pane { max-height: 100%; }
}
/* 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%; }
@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; }
.fc-keys { display: none; }
}

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import api from '../api/client'
import './FlashcardStudyPage.css'
import { useClaimFullBleed } from '../context/SessionChrome'
import RichText from '../components/RichText'
import ArticleSplitPane from '../components/ArticleSplitPane'
import { SplitViewProvider } from '../context/SplitViewContext'
@ -113,6 +114,11 @@ export default function FlashcardStudyPage() {
return () => window.removeEventListener('keydown', handler)
}, [next, prev, currentCard])
// 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
// navigation stays a deck is not a paper you can leave half-marked.
useClaimFullBleed(true)
const [splitTrail, setSplitTrail] = useState([])
const pushSplit = useCallback((slug) => setSplitTrail(
trail => (trail.at(-1) === slug ? trail : [...trail, slug])), [])
@ -132,22 +138,20 @@ export default function FlashcardStudyPage() {
<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. */}
<Link to="/flashcards" className="articles-back"> Cards</Link>
{/* Header */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
{/* The deck's bar: what you are studying, how far in, and the controls
that change the run. Fixed at the top the way the player's is a
counter that scrolls away with the content is a counter that leaves
exactly when a long card makes you want it. */}
<div className="fc-head">
<div className="fc-head-row">
<div>
<h2 style={{ marginBottom: 4 }}>{deck.title}</h2>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>
<h2>{deck.title}</h2>
<p className="fc-head-meta">
{cards.length} cards · {counts.due} due · {counts.unseen} new
{known.size > 0 && ` · ${known.size} answered`}
{known.size > 0 && ` · ${known.size} answered`} · {progress}% mastered
</p>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<div className="fc-head-tools">
<button className={`btn btn-sm ${mode === 'all' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('all'); setCurrentIdx(0); setFlipped(false) }}>All ({cards.length})</button>
{review.size > 0 && (
<button className={`btn btn-sm ${mode === 'review' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setMode('review'); setCurrentIdx(0); setFlipped(false) }}>Review ({review.size})</button>
@ -156,12 +160,8 @@ export default function FlashcardStudyPage() {
<button className="btn btn-sm btn-secondary" onClick={reset}>Reset</button>
</div>
</div>
{/* Progress bar */}
<div style={{ marginTop: 12 }}>
<div className="progress-bar" style={{ height: 6 }}>
<div className="fill" style={{ width: `${progress}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>{progress}% mastered</div>
<div className="progress-bar">
<div className="fill" style={{ width: `${progress}%`, transition: 'width 0.3s' }} />
</div>
</div>
@ -222,8 +222,13 @@ export default function FlashcardStudyPage() {
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. */}
{/* The player's own three-part bar: the way out at the left, the
navigation and the verdict in the middle, the keys at the right.
No Exit session a deck has no block to hand in, and the
controls that change the run are at the top where they belong. */}
<div className="fc-foot">
<div className="fc-foot-row">
<Link to="/flashcards" className="btn btn-secondary btn-sm"> All decks</Link>
<div className="fc-foot-nav">
<button className="btn btn-secondary" onClick={prev} disabled={currentIdx === 0}>&#8592; Prev</button>
{flipped ? (
<>
@ -235,7 +240,7 @@ export default function FlashcardStudyPage() {
)}
<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>
<p className="fc-keys">Space flips · moves · 1 got it · 2 review</p>
</div>
</>
)}