fix: one door to the questions, and difficulty as chevrons after the answer
**The rail was still drawn twice on a phone.** The fix for it was written next
to the rule it fixes, and lost: `.quiz-player.is-boxed .quiz-sidebar { display:
flex }` sits further down the file at equal specificity, and equal specificity
is settled by order. The narrow-screen rules now live at the end of the file,
with a comment saying why they are there.
**And a second door beside the first.** "Question 2 of 20 ☰" opened the same
list of questions as the menu in the header — a few pixels below the control
that is on every page in the same place. It is text now.
**The more-menu no longer opens off the screen.** It hung leftwards from a
button near the right edge, so on a phone half of it was outside the viewport
("…A FOLDER"). On a narrow screen it is a sheet above the bottom bar instead.
**Difficulty is three chevrons.** A word takes the width of a word, which is
why it only ever appeared where there was room for one; three marks fit a list
row and are read without reading. Where they appear is the part that matters:
in the player only once the answer is on screen, and in the rail only against
questions actually answered. Being told a question is easy while you are
working on it tells you how hard to look.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
cbff4ab073
commit
4b8d66cf2c
5 changed files with 103 additions and 19 deletions
15
frontend/src/components/Difficulty.css
Normal file
15
frontend/src/components/Difficulty.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* Three chevrons, lit up to the level. Sized to sit inside a list row without
|
||||
changing its height, which is the whole reason it is not a word. */
|
||||
.diff { display: inline-flex; align-items: center; vertical-align: middle; }
|
||||
.diff-said {
|
||||
position: absolute; width: 1px; height: 1px; overflow: hidden;
|
||||
clip-path: inset(50%); white-space: nowrap;
|
||||
}
|
||||
.diff-marks { display: inline-flex; gap: 1px; line-height: 1; }
|
||||
.diff-marks i {
|
||||
font-style: normal; font-weight: 800; font-size: 0.95rem; line-height: 1;
|
||||
color: var(--border);
|
||||
}
|
||||
.diff.is-easy .diff-marks i.is-on { color: #327b64; }
|
||||
.diff.is-medium .diff-marks i.is-on { color: #8a6417; }
|
||||
.diff.is-hard .diff-marks i.is-on { color: #a13c51; }
|
||||
36
frontend/src/components/Difficulty.jsx
Normal file
36
frontend/src/components/Difficulty.jsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import './Difficulty.css'
|
||||
|
||||
/**
|
||||
* How hard a question is, as three chevrons rather than a word.
|
||||
*
|
||||
* A word takes the width of a word, which is why it could only ever appear
|
||||
* where there was room for one. Three marks fit in a list row, and a reader
|
||||
* takes the level from them without reading anything.
|
||||
*
|
||||
* Where it may appear is the part that matters. Difficulty is a hint: knowing
|
||||
* a question is easy before answering it changes how hard you look, and
|
||||
* knowing it is hard is an excuse ready-made. So it is shown where somebody is
|
||||
* *choosing* or *reviewing* a question — a bank listing, an analysis, a
|
||||
* finished attempt — and inside a session only once the answer is on screen.
|
||||
* Nothing here enforces that; the caller decides, and every caller is expected
|
||||
* to have thought about it.
|
||||
*/
|
||||
const LEVELS = { easy: 1, medium: 2, hard: 3 }
|
||||
const NAMES = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
export default function Difficulty({ level, className = '' }) {
|
||||
const filled = LEVELS[level]
|
||||
if (!filled) return null
|
||||
return (
|
||||
<span className={`diff is-${level} ${className}`.trim()} title={NAMES[level]}>
|
||||
{/* The word goes to a screen reader, which gains nothing from three
|
||||
chevrons; the chevrons are decoration around it. */}
|
||||
<span className="diff-said">{NAMES[level]}</span>
|
||||
<span aria-hidden="true" className="diff-marks">
|
||||
{[1, 2, 3].map(step => (
|
||||
<i key={step} className={step <= filled ? 'is-on' : undefined}>›</i>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { optionLetter } from '../utils/options'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
import QuestionReadingLinks from '../components/QuestionReadingLinks'
|
||||
import Difficulty from '../components/Difficulty'
|
||||
import { useState, useEffect, useRef, useCallback, Suspense } from 'react'
|
||||
import lazyPage from '../utils/lazyPage'
|
||||
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
|
|
@ -1476,6 +1477,13 @@ const timerStarted = timeLeft !== null
|
|||
{/* One line each. The number already says which question it is, and a
|
||||
five-line excerpt makes a rail of twenty into a page of its own. */}
|
||||
{seen && <span className="quiz-rail-text">{excerpt}</span>}
|
||||
{/* The level, once the question has actually been answered — not
|
||||
merely reached. Seeing "easy" beside a question you are still
|
||||
working on tells you how hard to look, which is the one thing a
|
||||
difficulty mark must not do. */}
|
||||
{(isDone || reviewing) && q.difficulty && (
|
||||
<Difficulty level={q.difficulty} className="quiz-rail-diff-marks" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1762,17 +1770,13 @@ const timerStarted = timeLeft !== null
|
|||
onClick={() => (isLast ? endBlock() : safeNavigate(currentIdx + 1))}>Next</button>
|
||||
</div>
|
||||
</>
|
||||
) : hasRail ? (
|
||||
<p className="quiz-question-select is-static"><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount}</p>
|
||||
) : (
|
||||
/* Opens the same rail the desktop has, as a drawer. A dot grid
|
||||
dropped under the bar was a different thing in a different
|
||||
place doing the same job worse. */
|
||||
<button type="button" className="quiz-question-select" aria-expanded={navOpen}
|
||||
aria-label={`Question ${currentIdx + 1} of ${totalCount} — open the session`}
|
||||
onClick={() => setNavOpen(true)}>
|
||||
<small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount} ☰
|
||||
</button>
|
||||
/* Where you are, and nothing else. This used to be a button with
|
||||
a ☰ on it when there was no rail, which put a second door to
|
||||
the list of questions a few pixels below the one in the header
|
||||
— and the one in the header is the door that is always there,
|
||||
on every page, in the same place. */
|
||||
<p className="quiz-question-select is-static"><small>Question</small><strong>{currentIdx + 1}</strong> of {totalCount}</p>
|
||||
)}
|
||||
<div className="quiz-top-actions">
|
||||
{/* The exam's own tools, and only the exam's: a study session has
|
||||
|
|
@ -1814,7 +1818,7 @@ const timerStarted = timeLeft !== null
|
|||
to read next belongs in the explanation, which links to it. */}
|
||||
<div className="quiz-qmeta">
|
||||
{answerRevealed && current.difficulty && (
|
||||
<span className={`quiz-meta-pill is-${current.difficulty}`}>{current.difficulty}</span>
|
||||
<Difficulty level={current.difficulty} />
|
||||
)}
|
||||
{/* Only when it is not the ordinary kind. A pill reading
|
||||
"Multiple choice" above five lettered options is a label
|
||||
|
|
|
|||
|
|
@ -154,13 +154,15 @@ describe('quiz player', () => {
|
|||
// Category and difficulty are hints — being told a question is filed under
|
||||
// "hard" narrows the answer before the stem has been read, so it waits
|
||||
// until the answer is in.
|
||||
expect(within(meta).queryByText('hard')).not.toBeInTheDocument()
|
||||
// Shown as three chevrons rather than a word, so it fits a list row — the
|
||||
// word is what a screen reader is given.
|
||||
expect(within(meta).queryByText('Hard')).not.toBeInTheDocument()
|
||||
// And "Multiple choice" is not said at all: every question is, and a pill
|
||||
// repeating it above five lettered options labels the obvious.
|
||||
expect(within(meta).queryByText('Multiple choice')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
expect(await within(meta).findByText('hard')).toBeInTheDocument()
|
||||
expect(await within(meta).findByText('Hard')).toBeInTheDocument()
|
||||
// The category trail is gone: it named the answer's own topic, and led out
|
||||
// of a session you are part-way through.
|
||||
expect(within(meta).queryByRole('link', { name: 'Neonatology' })).not.toBeInTheDocument()
|
||||
|
|
|
|||
|
|
@ -427,12 +427,6 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
|||
/* One column; the question scrolls and the bar stays. */
|
||||
.quiz-player.is-boxed .quiz-layout > * { overflow-y: visible; }
|
||||
.quiz-player.is-boxed .quiz-layout { overflow-y: auto; }
|
||||
/* And no rail. The narrow rule that hides it is `.quiz-player
|
||||
.quiz-sidebar`, which the boxed player's own `display: flex` outranks —
|
||||
so on a phone the list of questions was drawn twice: once squeezed into
|
||||
the page above the question, and again in the drawer the burger opens.
|
||||
Same specificity here, later in the file, so this one wins. */
|
||||
.quiz-player.is-boxed .quiz-sidebar { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.quiz-player.is-boxed { height: calc(100dvh - 90px); }
|
||||
|
|
@ -963,3 +957,36 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
|
|||
.question-reading-chip.is-cards span { color: #8a6417; }
|
||||
.question-reading-chip.is-cards:hover { border-color: #b8860b; color: #8a6417; }
|
||||
.question-reading-chip.is-cards:hover span { color: #8a6417; }
|
||||
|
||||
|
||||
/* ── Narrow screens, last word ────────────────────────────────────────
|
||||
These live at the end of the file on purpose. Everything here competes with
|
||||
a rule of equal specificity further up — `.quiz-player.is-boxed
|
||||
.quiz-sidebar { display: flex }` among them — and equal specificity is
|
||||
settled by order, so a fix written next to the thing it fixes silently loses.
|
||||
That is exactly what happened to the first attempt at this: the list of
|
||||
questions went on being drawn twice on a phone, once squeezed into the page
|
||||
and once in the drawer. */
|
||||
@media (max-width: 1150px) {
|
||||
.quiz-player.is-boxed .quiz-sidebar,
|
||||
.quiz-player .quiz-sidebar { display: none; }
|
||||
}
|
||||
|
||||
/* The more-menu opens leftwards from a button near the right-hand edge, and on
|
||||
a phone that put half of it off the screen — "…A FOLDER", "…HIS SESSION".
|
||||
Pinned to the viewport instead of to the button. */
|
||||
@media (max-width: 640px) {
|
||||
.quiz-more { position: static; }
|
||||
.quiz-more-menu {
|
||||
/* A sheet above the bottom bar rather than a dropdown hanging off a
|
||||
button: `top: calc(100% + 6px)` on a fixed element means the bottom of
|
||||
the window, and there is no anchoring arithmetic that beats simply
|
||||
putting it where a thumb already is. */
|
||||
position: fixed; left: 12px; right: 12px; top: auto; bottom: 76px;
|
||||
width: auto; min-width: 0; max-height: 55dvh; overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The level, in the rail. Pushed to the right so a column of them lines up and
|
||||
can be read down rather than hunted for. */
|
||||
.quiz-rail-diff-marks { margin-left: auto; flex: none; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue