feat: attending tip, per-question notes, save to folder, session clock

The question toolbar now carries what a learner actually reaches for. An
attending tip — one sentence of the kind said at the bedside, stored separately
from the explanation because it is read before the answer is known and must not
give it away. A note of their own on that question, replacing a single global
note that was one page for everything and so was never about the question in
front of you. Saving to a folder, which the collections API has supported all
along with nothing in the player able to call it. And the share link, which
previously only appeared on the start screen.

Panels open one at a time under the toolbar; two at once would push the options
off screen.

Reset question resets one question, not the attempt: a misclick should cost the
answer you just gave, not the nineteen before it.

The clock shows session time, time on this question and the running average, in
study mode as well as exam mode — four minutes on one question is the number
that says whether you are learning or stuck, countdown or no countdown. It
pauses, because time spent making tea is not time spent thinking.

208 backend, 249 frontend green.

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-11 02:59:32 +02:00
parent d1388f3335
commit d34233d87b
6 changed files with 365 additions and 2 deletions

View file

@ -0,0 +1,47 @@
"""An attending's one-line pearl, and a learner's own note on a question.
Revision ID: d8e9f0a1b2c3
Revises: c7d8e9f0a1b2
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "d8e9f0a1b2c3"
down_revision = "c7d8e9f0a1b2"
branch_labels = None
depends_on = None
def _has(table: str, column: str) -> bool:
return column in {c["name"] for c in inspect(op.get_bind()).get_columns(table)}
def upgrade():
# The sentence an attending would say at the bedside — one idea, not a
# summary. Separate from the explanation because it is read before the
# answer is known, and must therefore never give it away.
if not _has("questions", "attending_tip"):
op.add_column("questions", sa.Column("attending_tip", sa.Text, nullable=True))
if "question_notes" not in inspect(op.get_bind()).get_table_names():
op.create_table(
"question_notes",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("question_id", sa.Integer,
sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("content", sa.Text, nullable=False),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
# A note belongs to one learner on one question; a second note on the
# same question is an edit of the first, not another note.
sa.UniqueConstraint("user_id", "question_id", name="uq_question_note"),
)
def downgrade():
op.drop_table("question_notes")
if _has("questions", "attending_tip"):
op.drop_column("questions", "attending_tip")

View file

@ -27,6 +27,9 @@ class Question(Base):
explanation_image_path = Column(String, nullable=True) explanation_image_path = Column(String, nullable=True)
option_explanations = Column(JSON, nullable=True) # {option_text: explanation} option_explanations = Column(JSON, nullable=True) # {option_text: explanation}
key_points = Column(JSON, nullable=True) # [{"text", "article_id"?, "article_section_id"?}] smart links key_points = Column(JSON, nullable=True) # [{"text", "article_id"?, "article_section_id"?}] smart links
# One sentence an attending would say at the bedside. Read before the answer
# is known, so it must point at the thinking without giving the answer away.
attending_tip = Column(Text, nullable=True)
difficulty = Column(String(10), nullable=True) # easy | medium | hard difficulty = Column(String(10), nullable=True) # easy | medium | hard
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it) is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)

View file

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@ -16,3 +16,21 @@ class UserNote(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship("User", back_populates="note") user = relationship("User", back_populates="note")
class QuestionNote(Base):
"""A learner's own note on one question.
Separate from the single global note, which was one page for everything and
so was never about the question in front of you.
"""
__tablename__ = "question_notes"
__table_args__ = (UniqueConstraint("user_id", "question_id", name="uq_question_note"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
content = Column(Text, nullable=False, default="")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View file

@ -83,6 +83,7 @@ class QuestionEdit(BaseModel):
additional_category_ids: list[int] | None = None # Full set of extra (non-primary) categories. additional_category_ids: list[int] | None = None # Full set of extra (non-primary) categories.
option_explanations: dict | None = None option_explanations: dict | None = None
key_points: list | None = None key_points: list | None = None
attending_tip: str | None = None
difficulty: Literal['easy', 'medium', 'hard'] | None = None difficulty: Literal['easy', 'medium', 'hard'] | None = None
image_path: str | None = None image_path: str | None = None
explanation_image_path: str | None = None explanation_image_path: str | None = None
@ -384,6 +385,7 @@ def get_question_bank(
"explanation_image_path": qu.explanation_image_path, "explanation_image_path": qu.explanation_image_path,
"option_explanations": qu.option_explanations, "option_explanations": qu.option_explanations,
"key_points": qu.key_points, "key_points": qu.key_points,
"attending_tip": qu.attending_tip,
"difficulty": qu.difficulty, "difficulty": qu.difficulty,
"user_id": qu.user_id, "user_id": qu.user_id,
"is_shared": qu.is_shared if qu.is_shared is not None else 1, "is_shared": qu.is_shared if qu.is_shared is not None else 1,
@ -415,6 +417,7 @@ class ManualQuestionCreate(BaseModel):
question_category_id: int | None = None question_category_id: int | None = None
option_explanations: dict | None = None option_explanations: dict | None = None
key_points: list | None = None key_points: list | None = None
attending_tip: str | None = None
difficulty: Literal['easy', 'medium', 'hard'] | None = None difficulty: Literal['easy', 'medium', 'hard'] | None = None
image_path: str | None = None image_path: str | None = None
explanation_image_path: str | None = None explanation_image_path: str | None = None
@ -453,6 +456,7 @@ def create_question_manually(
explanation_image_path=images["explanation_image_path"], explanation_image_path=images["explanation_image_path"],
option_explanations=option_explanations, option_explanations=option_explanations,
key_points=key_points, key_points=key_points,
attending_tip=(data.attending_tip or None),
difficulty=data.difficulty, difficulty=data.difficulty,
user_id=current_user.id, user_id=current_user.id,
is_shared=1, is_shared=1,
@ -708,7 +712,7 @@ def bulk_question_action(
MAX_VERSIONS = 5 MAX_VERSIONS = 5
VERSIONED_FIELDS = ("question_text", "question_type", "options", "correct_answer", VERSIONED_FIELDS = ("question_text", "question_type", "options", "correct_answer",
"explanation", "option_explanations", "key_points", "difficulty", "explanation", "option_explanations", "key_points", "attending_tip", "difficulty",
"question_category_id", "image_path", "explanation_image_path") "question_category_id", "image_path", "explanation_image_path")
@ -732,6 +736,47 @@ def _snapshot_question(db, question, user_id) -> None:
).delete(synchronize_session=False) ).delete(synchronize_session=False)
class QuestionNoteIn(BaseModel):
content: str = Field(default="", max_length=8000)
@router.get("/detail/{question_id}/note")
def read_question_note(question_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""This learner's own note on this question."""
from app.models.user_note import QuestionNote
row = db.query(QuestionNote).filter_by(
user_id=current_user.id, question_id=question_id).first()
return {"question_id": question_id, "content": row.content if row else "",
"updated_at": row.updated_at if row else None}
@router.put("/detail/{question_id}/note")
def write_question_note(question_id: int, data: QuestionNoteIn,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Save or clear it. Empty means delete, so an emptied note leaves no trace."""
from app.models.user_note import QuestionNote
if not db.query(Question.id).filter(Question.id == question_id).first():
raise HTTPException(404, "Question not found")
row = db.query(QuestionNote).filter_by(
user_id=current_user.id, question_id=question_id).first()
content = data.content.strip()
if not content:
if row:
db.delete(row)
db.commit()
return {"question_id": question_id, "content": ""}
if row:
row.content = content
else:
db.add(QuestionNote(user_id=current_user.id, question_id=question_id, content=content))
db.commit()
return {"question_id": question_id, "content": content}
@router.get("/detail/{question_id}/versions") @router.get("/detail/{question_id}/versions")
def list_question_versions( def list_question_versions(
question_id: int, question_id: int,
@ -808,6 +853,7 @@ def get_question_detail(
"explanation": question.explanation, "explanation": question.explanation,
"option_explanations": question.option_explanations, "option_explanations": question.option_explanations,
"key_points": question.key_points, "key_points": question.key_points,
"attending_tip": question.attending_tip,
"difficulty": question.difficulty, "difficulty": question.difficulty,
"question_category_id": question.question_category_id, "question_category_id": question.question_category_id,
"question_category_name": category.name if category else None, "question_category_name": category.name if category else None,

View file

@ -199,6 +199,46 @@ function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange
) )
} }
const clock = (seconds) => {
const s = Math.max(0, Math.round(seconds))
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
/**
* Session time, time on this question, and the running average.
*
* Shown in study mode as well as exam mode: knowing you have spent four minutes
* on one question is exactly as useful when nothing is counting down, and it is
* the number that tells you whether you are learning or stuck. It can be paused,
* because time spent making tea is not time spent thinking.
*/
function SessionClock({ sessionSeconds, questionSeconds, answered, paused, onTogglePause }) {
const average = answered > 0 ? sessionSeconds / answered : null
return (
<div className="quiz-clock">
<button type="button" className="quiz-clock-pause" aria-pressed={paused}
onClick={onTogglePause} title={paused ? 'Resume the clock' : 'Pause the clock'}>
{paused ? '▶' : '⏸'}
</button>
<span className="quiz-clock-cell">
<strong>{Math.floor(sessionSeconds / 3600)}h {String(Math.floor((sessionSeconds % 3600) / 60)).padStart(2, '0')}m</strong>
<em>session</em>
</span>
<span className="quiz-clock-cell">
<strong>{clock(questionSeconds)}</strong>
<em>question</em>
</span>
{average != null && (
<span className="quiz-clock-cell">
<strong>{clock(average)}</strong>
<em>average</em>
</span>
)}
{paused && <span className="quiz-clock-paused">paused</span>}
</div>
)
}
function TimerDisplay({ seconds, total }) { function TimerDisplay({ seconds, total }) {
const pct = total > 0 ? (seconds / total) * 100 : 100 const pct = total > 0 ? (seconds / total) * 100 : 100
const mins = Math.floor(seconds / 60), secs = seconds % 60 const mins = Math.floor(seconds / 60), secs = seconds % 60
@ -354,11 +394,23 @@ export default function QuizPage() {
const [expandedImagePath, setExpandedImagePath] = useState('') const [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1) const [imageZoom, setImageZoom] = useState(1)
const [startedAt, setStartedAt] = useState(null) const [startedAt, setStartedAt] = useState(null)
// Wall-clock seconds spent in this session and on the question in front of
// you. Kept here rather than derived from startedAt so pausing can stop them.
const [sessionSeconds, setSessionSeconds] = useState(0)
const [questionSeconds, setQuestionSeconds] = useState(0)
const [clockPaused, setClockPaused] = useState(false)
const [favorites, setFavorites] = useState([]) const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null) const [activeReadSegment, setActiveReadSegment] = useState(null)
const [manualHighlights, setManualHighlights] = useState({}) const [manualHighlights, setManualHighlights] = useState({})
const [draftAnswer, setDraftAnswer] = useState('') const [draftAnswer, setDraftAnswer] = useState('')
const [tool, setTool] = useState(null) const [tool, setTool] = useState(null)
// Which of the per-question panels is open. One at a time: they sit in the
// same place under the stem, and two at once would push the options off screen.
const [panel, setPanel] = useState(null)
const [note, setNote] = useState('')
const [noteSaved, setNoteSaved] = useState(true)
const [folders, setFolders] = useState([])
const [shareUrl, setShareUrl] = useState('')
const [showReview, setShowReview] = useState(false) const [showReview, setShowReview] = useState(false)
const [responseStats, setResponseStats] = useState(null) const [responseStats, setResponseStats] = useState(null)
const [statsError, setStatsError] = useState('') const [statsError, setStatsError] = useState('')
@ -710,7 +762,64 @@ const timerStarted = timeLeft !== null
} }
}, [attemptId, quizMode, saveProgressNow]) }, [attemptId, quizMode, saveProgressNow])
useEffect(() => {
if (clockPaused || !attemptId) return
const tick = setInterval(() => {
setSessionSeconds(v => v + 1)
setQuestionSeconds(v => v + 1)
}, 1000)
return () => clearInterval(tick)
}, [clockPaused, attemptId])
// Time on *this* question restarts when you move to another one.
useEffect(() => { setQuestionSeconds(0) }, [current?.id])
useEffect(() => {
if (!current?.id) return
let live = true
setPanel(null)
api.get(`/questions/detail/${current.id}/note`)
.then(res => { if (live) { setNote(res.data?.content || ''); setNoteSaved(true) } })
.catch(() => { if (live) { setNote(''); setNoteSaved(true) } })
return () => { live = false }
}, [current?.id])
useEffect(() => {
api.get('/collections/').then(res => setFolders(res.data || [])).catch(() => setFolders([]))
}, [])
useEffect(() => {
setShareUrl(quiz?.share_token ? `${window.location.origin}/share/${quiz.share_token}` : '')
}, [quiz?.share_token])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
// Reset one question rather than the whole attempt: a misclick should cost
// the answer you just gave, not the nineteen before it.
const resetQuestion = (questionId) => {
setAnswers(prev => {
const next = { ...prev }
delete next[questionId]
return next
})
setDraftAnswer('')
}
const saveNote = async (questionId, content) => {
setNoteSaved(false)
try {
await api.put(`/questions/detail/${questionId}/note`, { content })
setNoteSaved(true)
} catch { setNoteSaved(false) }
}
const saveToFolder = async (collectionId, questionId) => {
try {
await api.put(`/collections/${collectionId}/questions/${questionId}`)
setFolders(list => list.map(f => (f.id === collectionId
? { ...f, question_ids: [...(f.question_ids || []), questionId] } : f)))
} catch { /* the row stays unticked, which is the honest signal */ }
}
const chooseAnswer = value => { const chooseAnswer = value => {
if (!current || (isStudy && answers[current.id])) return if (!current || (isStudy && answers[current.id])) return
if (isStudy) setDraftAnswer(value) if (isStudy) setDraftAnswer(value)
@ -1115,6 +1224,23 @@ const timerStarted = timeLeft !== null
/> />
</div> </div>
<div className="quiz-actionbar" role="toolbar" aria-label="Question actions"> <div className="quiz-actionbar" role="toolbar" aria-label="Question actions">
{current.attending_tip && (
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
aria-pressed={panel === 'tip'}
onClick={() => setPanel(p => (p === 'tip' ? null : 'tip'))}>
<span>Attending tip</span>
</button>
)}
<button type="button" className={panel === 'note' ? 'is-on' : ''}
aria-pressed={panel === 'note'}
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}>
<span>{note ? 'Notes' : 'Add notes'}</span>
</button>
<button type="button" className={panel === 'save' ? 'is-on' : ''}
aria-pressed={panel === 'save'}
onClick={() => setPanel(p => (p === 'save' ? null : 'save'))}>
<span>Save</span>
</button>
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''} <button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
onClick={() => toggleFavorite(current.id)} onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}> title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}>
@ -1156,6 +1282,63 @@ const timerStarted = timeLeft !== null
</button> </button>
</div> </div>
</div> </div>
{/* The panels sit under the toolbar, where the eye already is, and
only one opens at a time: two would push the options off screen. */}
{panel === 'tip' && current.attending_tip && (
<div className="quiz-panel is-tip">
<span className="quiz-panel-mark" aria-hidden="true"></span>
<RichText value={current.attending_tip} />
</div>
)}
{panel === 'note' && (
<div className="quiz-panel">
<textarea className="quiz-note" value={note} rows={4}
aria-label="Your note on this question"
placeholder="Your own note on this question — Markdown supported. Only you can see it."
onChange={e => { setNote(e.target.value); setNoteSaved(false) }}
onBlur={() => saveNote(current.id, note)} />
<div className="quiz-panel-foot">
<span>{noteSaved ? 'Saved' : 'Unsaved'}</span>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => saveNote(current.id, note)}>Save note</button>
</div>
</div>
)}
{panel === 'save' && (
<div className="quiz-panel">
<p className="quiz-panel-title">Save to a folder</p>
{folders.length === 0 ? (
<p className="quiz-panel-empty">No folders yet make one in the question bank.</p>
) : (
<ul className="quiz-folders">
{folders.map(folder => {
const inIt = (folder.question_ids || []).includes(current.id)
return (
<li key={folder.id}>
<button type="button" className={inIt ? 'is-in' : ''} disabled={inIt}
onClick={() => saveToFolder(folder.id, current.id)}>
{inIt ? '✓ ' : '+ '}{folder.title}
</button>
</li>
)
})}
</ul>
)}
{shareUrl && (
<div className="quiz-share">
<p className="quiz-panel-title">Share this session</p>
<input readOnly value={shareUrl} aria-label="Share link"
onFocus={e => e.target.select()} />
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => navigator.clipboard?.writeText(shareUrl)}>Copy</button>
</div>
)}
</div>
)}
{current.image_path && ( {current.image_path && (
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button"> <button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
<img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration" <img src={uploadUrl(current.image_path, attemptId)} alt="Question illustration"
@ -1250,6 +1433,13 @@ const timerStarted = timeLeft !== null
<div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div> <div className="quiz-review-tabs"><span>Preferred response</span>{current.page_reference && <span className="quiz-source-page">Source page {current.page_reference}</span>}</div>
{responseStats && <p className="quiz-stats-note"> {responseStats && <p className="quiz-stats-note">
{showStats ? (responseStats.sample_size ? `Based on ${responseStats.sample_size} recorded answers` : 'No response statistics available yet') : 'Response statistics hidden'} {showStats ? (responseStats.sample_size ? `Based on ${responseStats.sample_size} recorded answers` : 'No response statistics available yet') : 'Response statistics hidden'}
<SessionClock sessionSeconds={sessionSeconds} questionSeconds={questionSeconds}
answered={answeredCount} paused={clockPaused}
onTogglePause={() => setClockPaused(v => !v)} />
<button type="button" className="quiz-stats-toggle"
onClick={() => resetQuestion(current.id)} disabled={!answers[current.id]}>
Reset question
</button>
<button type="button" className="quiz-stats-toggle" aria-pressed={showStats} onClick={toggleStats}> <button type="button" className="quiz-stats-toggle" aria-pressed={showStats} onClick={toggleStats}>
{showStats ? 'Hide stats' : 'Show stats'} {showStats ? 'Hide stats' : 'Show stats'}
</button> </button>

View file

@ -159,3 +159,62 @@
.quiz-response-stat { font-size: .7rem; } .quiz-response-stat { font-size: .7rem; }
} }
.quiz-restart-confirm { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--wrong-fg); background: var(--wrong-bg); border: 1px solid var(--wrong-bd); padding: 4px 10px; border-radius: 8px; } .quiz-restart-confirm { display: inline-flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--wrong-fg); background: var(--wrong-bg); border: 1px solid var(--wrong-bd); padding: 4px 10px; border-radius: 8px; }
/* Per-question panels: attending tip, your note, saving to a folder. They open
under the toolbar, one at a time, so the options stay on screen. */
.quiz-panel {
margin: 8px 0 14px; padding: 12px 14px;
background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
font-size: 0.88rem;
}
.quiz-panel.is-tip { display: flex; gap: 12px; align-items: flex-start; background: var(--expl-bg); border-color: var(--expl-bd); }
.quiz-panel-mark { flex-shrink: 0; font-size: 1.2rem; line-height: 1.4; }
.quiz-panel.is-tip .rich-text { font-style: italic; }
.quiz-panel-title { margin: 0 0 8px; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); }
.quiz-panel-empty { margin: 0; color: var(--text-muted); font-size: 0.84rem; }
.quiz-panel-foot { display: flex; align-items: center; gap: 10px; margin-top: 8px; font-size: 0.76rem; color: var(--text-muted); }
.quiz-note {
width: 100%; min-height: 110px; padding: 10px 12px; resize: vertical;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.88rem; line-height: 1.6;
}
.quiz-folders { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.quiz-folders button {
min-height: 32px; padding: 5px 11px; cursor: pointer;
border: 1px solid var(--border); border-radius: 999px; background: var(--card-bg);
font: inherit; font-size: 0.8rem; color: var(--text);
}
.quiz-folders button:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); }
.quiz-folders button.is-in { background: var(--correct-bg); border-color: var(--correct-bd); color: var(--correct-fg); cursor: default; }
.quiz-share { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.quiz-share .quiz-panel-title { width: 100%; margin: 0; }
.quiz-share input {
flex: 1; min-width: 180px; padding: 7px 10px; font-size: 0.8rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--input-bg); color: var(--text-muted);
}
/* Session, this question, and the running average in study mode too, because
"four minutes on one question" is the number that says whether you are
learning or stuck, countdown or no countdown. */
.quiz-clock { display: inline-flex; align-items: center; gap: 12px; margin-right: auto; }
.quiz-clock-pause {
width: 28px; height: 28px; flex-shrink: 0; cursor: pointer;
border: 1px solid var(--border); border-radius: 7px; background: var(--card-bg);
font-size: 0.72rem; color: var(--text-muted); line-height: 1;
}
.quiz-clock-pause:hover { border-color: var(--primary); color: var(--primary); }
.quiz-clock-cell { display: flex; flex-direction: column; line-height: 1.15; }
.quiz-clock-cell strong { font-size: 0.86rem; font-variant-numeric: tabular-nums; color: var(--text); }
.quiz-clock-cell em {
font-style: normal; font-size: 0.6rem; font-weight: 700;
letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle);
}
.quiz-clock-paused { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #b45309; }
@media (max-width: 640px) {
.quiz-clock { width: 100%; margin-right: 0; margin-bottom: 8px; }
}