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]} + + + ) +} 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; }