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:
Daniel 2026-09-12 22:10:42 +02:00
parent cbff4ab073
commit 4b8d66cf2c
5 changed files with 103 additions and 19 deletions

View 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; }

View 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>
)
}

View file

@ -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

View file

@ -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()

View file

@ -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; }