feat: session rail with excerpts, and articles that read as one page
Quiz runner — session rail The desktop sidebar was `display: none` in the player theme, so the only way to move between questions was a dropdown that showed numbers and nothing else. It is now the left rail a Qbank session has: one row per question with its number, an excerpt of the stem, its difficulty, and a mark for bookmarked ones. The active row is marked with aria-current, answered rows turn green, and the rail sticks while the question scrolls. Below 1150px there is no room for both, so the topbar dropdown takes over as before. Article pages — read straight through Each section opened in a modal overlay, which meant a topic could not be read end to end: you opened a section, read it, closed it, opened the next. Sections now render inline as one page under a sticky contents rail, with the last-edited date, matching how the reference reads. Deep links still work — `?section=` now scrolls to and highlights the section instead of trapping the reader in a dialog, and the rest of the article stays visible around it. `scrollIntoView` is called defensively: it does not exist in every environment. Tests: 2 new runner tests (rail lists number, excerpt and difficulty; navigates and follows the active row; marks answered after a study response is submitted). Stem assertions are now scoped to the question card, since the rail repeats each stem as an excerpt. Article tests assert sections are inline and no dialog opens. Full suites green: 96 backend, 135 frontend, build clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
This commit is contained in:
parent
878e61c69b
commit
e9c8bfd204
6 changed files with 174 additions and 40 deletions
|
|
@ -27,6 +27,10 @@
|
|||
.article-content img { max-width: 100%; border-radius: 8px; }
|
||||
.article-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
.article-content td, .article-content th { border: 1px solid var(--border); padding: 6px 10px; }
|
||||
.article-updated { font-size: .76rem; color: var(--text-subtle); margin: 0 0 10px; }
|
||||
.article-section { scroll-margin-top: 84px; padding-top: 6px; margin-top: 26px; border-top: 1px solid var(--border); }
|
||||
.article-section > h2 { margin: 16px 0 8px; font-size: 1.15rem; }
|
||||
.article-section.is-target > h2 { box-shadow: -10px 0 0 var(--primary); }
|
||||
.article-summary { font-size: .95rem; color: var(--text-muted); border-left: 3px solid var(--primary); padding-left: 10px; margin: 0 0 16px; }
|
||||
.article-drawer-toggle { display: none; margin-bottom: 10px; }
|
||||
.article-linked { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
|
|
@ -61,6 +65,26 @@
|
|||
.comment-content p { margin: 0 0 4px; }
|
||||
.comment-actions { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.comment-load-more { margin-top: 10px; }
|
||||
.comment-section { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 14px; }
|
||||
.comment-heading { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.comment-heading h3 { margin: 0; font-size: 1rem; }
|
||||
.comment-subtitle { font-size: .74rem; color: var(--text-muted); }
|
||||
.comment-compose { background: var(--input-bg); border: 1px solid var(--border); border-radius: 10px; padding: 10px; margin-bottom: 14px; }
|
||||
.comment-input { width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: .88rem; resize: vertical; }
|
||||
.comment-compose-footer { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||
.comment-count { margin-left: auto; font-size: .72rem; color: var(--text-muted); }
|
||||
.comment-empty { color: var(--text-muted); font-size: .84rem; margin: 6px 0 0; }
|
||||
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.comment { display: flex; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); }
|
||||
.comment-avatar { width: 30px; height: 30px; border-radius: 50%; background: var(--primary-soft, #dcebfa); color: var(--primary); font-weight: 700; display: flex; align-items: center; justify-content: center; font-size: .9rem; flex-shrink: 0; }
|
||||
.comment-body { flex: 1; min-width: 0; }
|
||||
.comment-meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; font-size: .84rem; }
|
||||
.comment-meta span { color: var(--text-muted); font-size: .74rem; }
|
||||
.comment-badge { font-style: normal; font-size: .66rem; font-weight: 700; text-transform: uppercase; color: #92400e; background: #fef3c7; padding: 1px 8px; border-radius: 10px; }
|
||||
.comment-content { font-size: .88rem; }
|
||||
.comment-content p { margin: 0 0 4px; }
|
||||
.comment-actions { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.comment-load-more { margin-top: 10px; }
|
||||
.article-section-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35); z-index: 1050; display: flex; justify-content: flex-end; }
|
||||
.article-section-panel { background: var(--card-bg); width: min(560px, 92vw); height: 100%; display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,0.18); animation: article-slide-in .18s ease; }
|
||||
@keyframes article-slide-in { from { transform: translateX(24px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
|
|
@ -108,3 +132,8 @@
|
|||
.article-practise-controls label { flex: 1; }
|
||||
.article-practise-controls .btn { width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.article-section { margin-top: 20px; }
|
||||
.article-section > h2 { font-size: 1.06rem; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,11 +193,22 @@ export function ArticlePage() {
|
|||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const section = article?.sections?.find(s => s.id === activeSection)
|
||||
// Deep link: scroll to the section once it has rendered.
|
||||
useEffect(() => {
|
||||
if (!activeSection || !article) return
|
||||
const target = document.getElementById(`section-${activeSection}`)
|
||||
target?.scrollIntoView?.({ block: 'start' })
|
||||
}, [activeSection, article])
|
||||
|
||||
/** Jump to a section in the page and record it in the URL for deep links. */
|
||||
const openSection = (secId) => {
|
||||
setActiveSection(secId)
|
||||
setSearchParams(secId ? { section: secId } : {})
|
||||
setDrawerOpen(false)
|
||||
if (secId) {
|
||||
const target = document.getElementById(`section-${secId}`)
|
||||
target?.scrollIntoView?.({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
const save = async (publish = null) => {
|
||||
|
|
@ -363,11 +374,26 @@ export function ArticlePage() {
|
|||
</ul>
|
||||
</aside>
|
||||
<main className="article-content">
|
||||
{article.updated_at && (
|
||||
<p className="article-updated">Last edited {new Date(article.updated_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}</p>
|
||||
)}
|
||||
{article.summary && <p className="article-summary">{article.summary}</p>}
|
||||
{article.content && <Markdown>{article.content}</Markdown>}
|
||||
{!article.sections?.length && (!article.summary && !article.content) && (
|
||||
<div className="empty-state">Content is being prepared by educators.</div>
|
||||
)}
|
||||
|
||||
{/* Sections read straight through, as one page. Opening each in a
|
||||
modal made a topic impossible to read end to end. */}
|
||||
{(article.sections || []).map(sec => (
|
||||
<section key={sec.id} id={`section-${sec.id}`}
|
||||
className={`article-section${activeSection === sec.id ? ' is-target' : ''}`}
|
||||
aria-labelledby={`heading-${sec.id}`}>
|
||||
<h2 id={`heading-${sec.id}`}>{sec.title}</h2>
|
||||
<Markdown>{sec.content}</Markdown>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<PractiseTopic article={article} canEdit={canEdit} questions={questions} onUnlink={unlinkQuestion} />
|
||||
{cards.length > 0 && (
|
||||
<div className="article-linked">
|
||||
|
|
@ -382,20 +408,6 @@ export function ArticlePage() {
|
|||
)}
|
||||
<CommentSection articleId={article.id} />
|
||||
</main>
|
||||
{section && (
|
||||
<div className="article-section-overlay" role="dialog" aria-label={`Section: ${section.title}`}>
|
||||
<div className="article-section-panel">
|
||||
<header className="article-section-panel-header">
|
||||
<h2>{section.title}</h2>
|
||||
<button type="button" aria-label="Close section" onClick={() => openSection('')}>✕</button>
|
||||
</header>
|
||||
<div className="article-section-body"><Markdown>{section.content}</Markdown></div>
|
||||
<footer className="article-section-panel-footer">
|
||||
<Link to={`/articles/${article.id}`} onClick={() => openSection('')}>← {article.title}</Link>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -44,13 +44,15 @@ describe('topic reading', () => {
|
|||
expect(await screen.findByRole('heading', { name: 'Febrile seizures' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
|
||||
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Initial workup' }))
|
||||
// The section opens in a closable overlay while the main article stays visible.
|
||||
const overlay = await screen.findByRole('dialog', { name: 'Section: Initial workup' })
|
||||
expect(within(overlay).getByText('Section markdown')).toBeInTheDocument()
|
||||
// Sections read inline as one page, so the body is present without opening anything.
|
||||
expect(screen.getByRole('heading', { name: 'Initial workup' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Section markdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
||||
await userEvent.click(within(overlay).getByRole('button', { name: 'Close section' }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
|
||||
// The contents rail jumps to a section and marks it active.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Initial workup' }))
|
||||
expect(screen.getByRole('button', { name: 'Initial workup' })).toHaveClass('active')
|
||||
// Reading a topic offers a test; it never prints the stem, answer or explanation.
|
||||
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
|
||||
|
|
@ -90,9 +92,11 @@ describe('topic reading', () => {
|
|||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
render(<MemoryRouter initialEntries={[`/articles/1?section=${'b'.repeat(32)}`]}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
|
||||
const overlay = await screen.findByRole('dialog', { name: 'Section: Second section' })
|
||||
expect(within(overlay).getByText('Second body')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Second section' })).toHaveClass('active')
|
||||
// A deep link marks the section without hiding the rest of the article.
|
||||
expect(await screen.findByRole('button', { name: 'Second section' })).toHaveClass('active')
|
||||
expect(screen.getByText('Second body')).toBeInTheDocument()
|
||||
expect(screen.getByText('First body')).toBeInTheDocument()
|
||||
expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
|
||||
expect(document.querySelector(`#section-${'b'.repeat(32)}`)).toHaveClass('is-target')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -939,6 +939,29 @@ const timerStarted = timeLeft !== null
|
|||
}
|
||||
}
|
||||
|
||||
const QuestionRailItem = ({ q, i }) => {
|
||||
const isActive = i === currentIdx
|
||||
const isDone = !!answers[q.id]
|
||||
const marked = favorites.includes(q.id)
|
||||
const excerpt = questionStem(q).replace(/\s+/g, ' ').trim()
|
||||
return (
|
||||
<button type="button"
|
||||
className={`quiz-rail-item${isActive ? ' is-active' : ''}${isDone ? ' is-done' : ''}`}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
onClick={() => { safeNavigate(i); setNavOpen(false) }}>
|
||||
<span className="quiz-rail-num">
|
||||
{i + 1}
|
||||
{marked && <span className="quiz-rail-mark" aria-label="Marked">★</span>}
|
||||
</span>
|
||||
<span className="quiz-rail-body">
|
||||
<span className="quiz-rail-label">Question {i + 1}</span>
|
||||
<span className="quiz-rail-text">{excerpt.slice(0, 64)}{excerpt.length > 64 ? '…' : ''}</span>
|
||||
{q.difficulty && <span className={`quiz-rail-diff is-${q.difficulty}`}>{q.difficulty}</span>}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const QuestionDot = ({ q, i }) => {
|
||||
const isActive = i === currentIdx
|
||||
const isDone = !!answers[q.id]
|
||||
|
|
@ -1326,13 +1349,11 @@ const timerStarted = timeLeft !== null
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="quiz-sidebar">
|
||||
<div style={{ fontWeight: 700, fontSize: '0.78rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
Questions
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{questions.map((q, i) => <QuestionDot key={q.id} q={q} i={i} />)}
|
||||
{/* Desktop rail — numbers with an excerpt, as in a Qbank session */}
|
||||
<div className="quiz-sidebar quiz-rail">
|
||||
<div className="quiz-rail-head">Session questions</div>
|
||||
<div className="quiz-rail-list">
|
||||
{questions.map((q, i) => <QuestionRailItem key={q.id} q={q} i={i} />)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
|
|
|
|||
|
|
@ -50,13 +50,51 @@ beforeEach(() => {
|
|||
function mount(entry = '/quizzes/10') {
|
||||
render(<MemoryRouter initialEntries={[entry]}><Routes><Route path="/quizzes/:id" element={<QuizPage />} /><Route path="/results/:id" element={<div>Submitted results</div>} /></Routes></MemoryRouter>)
|
||||
}
|
||||
/** The session rail repeats each stem as an excerpt, so stem lookups are
|
||||
* scoped to the question card to stay unambiguous. */
|
||||
const inCard = () => within(document.querySelector('.question-card'))
|
||||
const findStem = async (text) => {
|
||||
await waitFor(() => expect(document.querySelector('.question-card')).toBeInTheDocument())
|
||||
return waitFor(() => inCard().getByText(text))
|
||||
}
|
||||
|
||||
async function begin(study = true) {
|
||||
quizModeVar = study ? 'learning' : 'timed'
|
||||
mount()
|
||||
await screen.findByText('Full first clinical question.')
|
||||
await findStem('Full first clinical question.')
|
||||
}
|
||||
|
||||
describe('quiz player', () => {
|
||||
it('lists every question in the rail with a number, excerpt and difficulty', async () => {
|
||||
await begin()
|
||||
const rail = document.querySelector('.quiz-rail-list')
|
||||
expect(rail).toBeInTheDocument()
|
||||
|
||||
const items = rail.querySelectorAll('.quiz-rail-item')
|
||||
expect(items).toHaveLength(2)
|
||||
expect(within(items[0]).getByText('Question 1')).toBeInTheDocument()
|
||||
expect(within(items[0]).getByText('Full first clinical question.')).toBeInTheDocument()
|
||||
expect(within(items[0]).getByText('hard')).toBeInTheDocument()
|
||||
expect(items[0]).toHaveAttribute('aria-current', 'true')
|
||||
expect(items[1]).not.toHaveAttribute('aria-current')
|
||||
|
||||
// The rail navigates, and the active row follows.
|
||||
await userEvent.click(items[1])
|
||||
await findStem('Full second clinical question.')
|
||||
const after = document.querySelectorAll('.quiz-rail-item')
|
||||
expect(after[1]).toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
|
||||
it('marks answered questions in the rail', async () => {
|
||||
await begin()
|
||||
const before = document.querySelectorAll('.quiz-rail-item')
|
||||
expect(before[0].className).not.toMatch(/is-done/)
|
||||
await userEvent.click(inCard().getByText('First answer').closest('.option'))
|
||||
// Study mode holds a draft until it is submitted, so the rail marks it then.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Submit response' }))
|
||||
await waitFor(() => expect(document.querySelectorAll('.quiz-rail-item')[0].className).toMatch(/is-done/))
|
||||
})
|
||||
|
||||
it('keeps question metadata on one compact strip with a single action bar', async () => {
|
||||
await begin()
|
||||
// The category trail used to be a tall wrapping block above the stem.
|
||||
|
|
@ -106,7 +144,7 @@ describe('quiz player', () => {
|
|||
expect(api.post).not.toHaveBeenCalled()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry resume' }))
|
||||
// Retry loads the quiz and starts it straight away in its own mode — no re-ask.
|
||||
expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument()
|
||||
expect(await findStem('Full first clinical question.')).toBeInTheDocument()
|
||||
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
|
||||
})
|
||||
|
||||
|
|
@ -132,7 +170,7 @@ describe('quiz player', () => {
|
|||
})
|
||||
mount('/quizzes/10?return_to=%2Fcourses%2F1')
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Begin Quiz' }))
|
||||
await screen.findByText('Full first clinical question.')
|
||||
await findStem('Full first clinical question.')
|
||||
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
|
||||
expect(screen.queryByText(/Full explanation/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -147,7 +185,7 @@ describe('quiz player', () => {
|
|||
await userEvent.click(screen.getByRole('button', { name: '⏸ Suspend' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Suspend & Leave' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Keep this tab open')
|
||||
expect(screen.getByText('Full first clinical question.')).toBeInTheDocument()
|
||||
expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument()
|
||||
failSaving = false
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry saving' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
|
|
@ -181,7 +219,7 @@ describe('quiz player', () => {
|
|||
mount()
|
||||
expect(await screen.findByText(/Time expired while you were away/)).toBeInTheDocument()
|
||||
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
|
||||
expect(screen.getByText('Full first clinical question.')).toBeInTheDocument()
|
||||
expect(inCard().getByText('Full first clinical question.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows per-option explanations in study feedback behind a toggle', async () => {
|
||||
|
|
@ -281,7 +319,7 @@ describe('quiz player', () => {
|
|||
const review = screen.getByRole('dialog', { name: 'Review & Complete' })
|
||||
expect(within(review).getByText(/1 of 2 questions answered/)).toBeInTheDocument()
|
||||
await userEvent.click(within(review).getByRole('button', { name: '2 · Unanswered' }))
|
||||
await screen.findByText('Full second clinical question.')
|
||||
await findStem('Full second clinical question.')
|
||||
expect(api.post.mock.calls.some(([url]) => url === '/attempts/50/submit')).toBe(false)
|
||||
await userEvent.click(screen.getAllByRole('button', { name: 'Review & Complete' })[0])
|
||||
await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Complete test' }))
|
||||
|
|
@ -291,7 +329,7 @@ describe('quiz player', () => {
|
|||
|
||||
it('starts timed quizzes in exam mode without a mode prompt', async () => {
|
||||
mount()
|
||||
expect(await screen.findByText('Full first clinical question.')).toBeInTheDocument()
|
||||
expect(await findStem('Full first clinical question.')).toBeInTheDocument()
|
||||
expect(api.post).toHaveBeenCalledWith('/attempts/start?quiz_id=10&mode=exam')
|
||||
expect(screen.queryByRole('button', { name: /Study Mode|Exam Mode/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -323,7 +361,7 @@ describe('quiz player', () => {
|
|||
return originalPost(url, ...args)
|
||||
})
|
||||
mount()
|
||||
await screen.findByText('Full first clinical question.')
|
||||
await findStem('Full first clinical question.')
|
||||
await screen.findByRole('button', { name: 'Make shareable' })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Make shareable' }))
|
||||
await screen.findByRole('button', { name: 'Copy share link' })
|
||||
|
|
|
|||
|
|
@ -4,8 +4,38 @@
|
|||
.quiz-player .quiz-header-title { font-size: .9rem; font-weight: 500; color: #737982; }
|
||||
.quiz-player .quiz-code-badge { font-size: .75rem; }
|
||||
.quiz-player .quiz-code-badge code { background: transparent; border: 0; }
|
||||
.quiz-player .quiz-layout { display: block; }
|
||||
.quiz-player .quiz-sidebar { display: none; }
|
||||
/* Session rail beside the question, like a Qbank session's left column.
|
||||
Below 1150px there is not room for both, so the topbar dropdown takes over. */
|
||||
.quiz-player .quiz-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 28px; align-items: start; }
|
||||
.quiz-player .quiz-sidebar { display: block; order: -1; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow-y: auto; }
|
||||
.quiz-rail-head { font-size: .68rem; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: #8b929c; padding: 0 0 8px; }
|
||||
.quiz-rail-list { display: flex; flex-direction: column; }
|
||||
.quiz-rail-item {
|
||||
display: flex; gap: 10px; align-items: flex-start; width: 100%;
|
||||
padding: 9px 8px; background: none; border: 0; border-left: 3px solid transparent;
|
||||
font: inherit; text-align: left; cursor: pointer; color: #4a5058;
|
||||
}
|
||||
.quiz-rail-item:hover { background: #f4f6fb; }
|
||||
.quiz-rail-item.is-active { background: #eaf0fa; border-left-color: #496fa5; color: #253038; }
|
||||
.quiz-rail-num {
|
||||
flex-shrink: 0; width: 26px; height: 26px; border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center; position: relative;
|
||||
font-size: .76rem; font-weight: 650; background: #edeef2; color: #6f767f;
|
||||
}
|
||||
.quiz-rail-item.is-done .quiz-rail-num { background: #dff0e8; color: #327b64; }
|
||||
.quiz-rail-item.is-active .quiz-rail-num { background: #496fa5; color: #fff; }
|
||||
.quiz-rail-mark { position: absolute; top: -4px; right: -4px; font-size: .6rem; color: #d99a2b; }
|
||||
.quiz-rail-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.quiz-rail-label { font-size: .68rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #98a0aa; }
|
||||
.quiz-rail-text { font-size: .78rem; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.quiz-rail-diff { align-self: flex-start; font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; border-radius: 10px; padding: 1px 7px; margin-top: 2px; }
|
||||
.quiz-rail-diff.is-easy { background: #eef7f3; color: #327b64; }
|
||||
.quiz-rail-diff.is-medium { background: #fdf6e8; color: #8a6417; }
|
||||
.quiz-rail-diff.is-hard { background: #fbecf0; color: #a13c51; }
|
||||
@media (max-width: 1150px) {
|
||||
.quiz-player .quiz-layout { display: block; }
|
||||
.quiz-player .quiz-sidebar { display: none; }
|
||||
}
|
||||
.quiz-player .quiz-nav-toggle { display: inline-flex; }
|
||||
.quiz-player .progress-bar { height: 3px; }
|
||||
.quiz-topbar { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 10px 0 20px; margin-bottom: 0; border-bottom: 1px solid var(--border); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue