diff --git a/frontend/src/components/Difficulty.css b/frontend/src/components/Difficulty.css
new file mode 100644
index 0000000..e2a3480
--- /dev/null
+++ b/frontend/src/components/Difficulty.css
@@ -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; }
diff --git a/frontend/src/components/Difficulty.jsx b/frontend/src/components/Difficulty.jsx
new file mode 100644
index 0000000..6c66d8d
--- /dev/null
+++ b/frontend/src/components/Difficulty.jsx
@@ -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 (
+
+ {/* The word goes to a screen reader, which gains nothing from three
+ chevrons; the chevrons are decoration around it. */}
+ {NAMES[level]}
+
+ {[1, 2, 3].map(step => (
+ ›
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx
index dcdcfce..ddc9e8f 100644
--- a/frontend/src/pages/QuizPage.jsx
+++ b/frontend/src/pages/QuizPage.jsx
@@ -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 && {excerpt}}
+ {/* 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 && (
+
+ )}
)
}
@@ -1762,17 +1770,13 @@ const timerStarted = timeLeft !== null
onClick={() => (isLast ? endBlock() : safeNavigate(currentIdx + 1))}>Next
>
- ) : hasRail ? (
-
Question{currentIdx + 1} of {totalCount}
) : (
- /* 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. */
-
+ /* 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. */
+
Question{currentIdx + 1} of {totalCount}
)}
{/* 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. */}
{answerRevealed && current.difficulty && (
- {current.difficulty}
+
)}
{/* Only when it is not the ordinary kind. A pill reading
"Multiple choice" above five lettered options is a label
diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx
index 38327d9..ecfffb6 100644
--- a/frontend/src/pages/QuizPage.test.jsx
+++ b/frontend/src/pages/QuizPage.test.jsx
@@ -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()
diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css
index 8ddc236..e8480e1 100644
--- a/frontend/src/pages/QuizPlayer.css
+++ b/frontend/src/pages/QuizPlayer.css
@@ -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; }