fix: the phone pass — figures, drawers over panes, and the player's chrome

- A diagram opened on a phone was a caption above an empty screen. The SVGs
  are written with a viewBox and no width or height, so they have a shape but
  no size: Chrome falls back to 300×150, Safari resolves them to nothing inside
  a shrink-to-fit box. Vector figures now get the width they are allowed and
  take their height from their shape. Photographs are untouched — the same rule
  would blow them up.
- "Source: Schematic drawn for PedsHub." now reads "Source: PedsHub", on all
  five drawings, set through the API.
- A drawer opened from the header appeared behind the split-view pane. It is
  the header's own control, and the header is above everything, so the drawer
  is too — and its backdrop now starts below the header rather than covering
  the button that opened it.
- The session header repeated the question counter that the bar above the
  question already gives, and on a 390px screen those two rows were a fifth of
  the scroller, above every question. The counter goes; on a phone the whole
  header card goes, because the drawer already carries the session's name, its
  mode and how much is answered.
- Every control in the question's toolbar now names itself. Below 800px the
  words are hidden and only the glyph is left, so a button whose name lived in
  that hidden span had no name at all to a screen reader and no tooltip to a
  long press. "Clear highlights" is no longer a second pencil an inch from the
  pencil that opens the note.
- The last line of an explanation sat flush on the bar at the foot of the
  player, which reads as content cut off rather than content finished.

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 23:50:36 +02:00
parent b345709c4b
commit c1391c1554
7 changed files with 74 additions and 13 deletions

View file

@ -9,6 +9,8 @@
}
.imgfig-thumb:hover { border-color: var(--primary); }
.imgfig-thumb img { display: block; max-width: 260px; max-height: 200px; width: auto; height: auto; }
.imgfig-thumb.is-vector { width: min(100%, 260px); }
.imgfig-thumb.is-vector img { width: 100%; height: auto; }
.imgfig-label { max-width: 260px; font-size: 0.78rem; line-height: 1.4; color: var(--text-muted); }
/* The viewer
@ -57,6 +59,11 @@
its content rather than a flex child that stretches. */
.imgfig-frame { position: relative; display: block; line-height: 0; cursor: default; }
.imgfig-frame img { display: block; max-width: 100%; max-height: calc(100dvh - 110px); width: auto; height: auto; }
/* A drawing has no size of its own, only a shape, so it is given the width it
is allowed and takes its height from that. Line art scales; a photograph
would be blown up by the same rule, which is why this is not the default. */
.imgfig-frame.is-vector { width: min(100%, 900px); }
.imgfig-frame.is-vector img { width: 100%; height: auto; max-height: calc(100dvh - 160px); object-fit: contain; }
.imgov { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
@media (max-width: 820px) {
@ -67,6 +74,10 @@
.imgfig-desc { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .1); padding: 16px 16px 14px; gap: 10px; }
.imgfig-stage { padding: 12px; }
.imgfig-frame img { max-height: none; }
/* Stacked, so the picture has the width of the screen and whatever height
its shape asks for the row it sits in scrolls with the description. */
.imgfig-frame.is-vector { width: 100%; }
.imgfig-frame.is-vector img { max-height: none; }
}
@media (max-width: 560px) {

View file

@ -23,6 +23,13 @@ import './ImageFigure.css'
* Anything an educator has marked on the image is off until the learner turns
* it on. Marks shown before they have looked answer the question for them.
*/
//: An SVG written with a viewBox and no width or height has a ratio but no
//: intrinsic size. Chrome falls back to 300×150; Safari resolves it to nothing
//: at all inside a shrink-to-fit box, which is why a diagram opened on an
//: iPhone was a caption above an empty screen. Told apart here rather than in
//: CSS, which cannot see a file extension, and given a width to work from.
const isVector = (src) => /\.svg(\?|#|$)/i.test(String(src || ''))
export function ImageViewer({ src, alt = '', attemptId, onClose }) {
const [asset, setAsset] = useState(null)
const [marked, setMarked] = useState(false)
@ -88,7 +95,7 @@ export function ImageViewer({ src, alt = '', attemptId, onClose }) {
<span className="imgfig-stage" onClick={event => {
if (event.target === event.currentTarget) onClose?.()
}}>
<span className="imgfig-frame">
<span className={`imgfig-frame${isVector(src) ? ' is-vector' : ''}`}>
<img src={uploadUrl(src, attemptId)} alt={title || label} />
{marked && hasShapes && <ImageOverlay overlay={overlay} />}
</span>
@ -107,7 +114,7 @@ export default function ImageFigure({ src, alt = '', attemptId, className = '' }
return (
<>
<span className={`imgfig ${className}`.trim()}>
<button type="button" className="imgfig-thumb" onClick={() => setOpen(true)}
<button type="button" className={`imgfig-thumb${isVector(src) ? ' is-vector' : ''}`} onClick={() => setOpen(true)}
aria-label={label ? `Open the figure: ${label}` : 'Open the figure'}>
{/* Lazy: a page of prose with six figures should not fetch six
images to show the first paragraph. */}

View file

@ -162,7 +162,7 @@
/* A drawer from the left, like the session's questions and an article's
contents not a panel that pushes the conversation down the page. */
.ai-rail {
position: fixed; top: var(--ai-top, 0px); bottom: 0; left: 0; z-index: 45;
position: fixed; top: var(--ai-top, 0px); bottom: 0; left: 0; z-index: 1250;
width: min(320px, 86vw); max-height: none; overflow-y: auto;
background: var(--card-bg); border-right: 1px solid var(--border);
box-shadow: 8px 0 28px rgba(15, 23, 42, .18);
@ -170,7 +170,10 @@
transition: transform .18s ease, visibility .18s;
}
.ai-rail.is-open { transform: none; visibility: visible; }
.ai-rail-backdrop { position: fixed; inset: 0; z-index: 44; background: rgba(15, 23, 42, .38); }
.ai-rail-backdrop {
position: fixed; top: var(--ai-top, 0px); left: 0; right: 0; bottom: 0;
z-index: 1249; background: rgba(15, 23, 42, .38);
}
.ai-rail-toggle { display: none; }
.ai-msg.is-user { max-width: 88%; }
.ai-hero-title { font-size: 1.35rem; }

View file

@ -135,7 +135,10 @@
.article-sections {
position: fixed;
top: var(--article-top); bottom: 0; left: 0;
z-index: 45;
/* Above the split pane and above an open figure: the menu is pressed from
the header, which is on top of both, so a drawer that opened behind them
looked like a button that did nothing. */
z-index: 1250;
width: min(320px, 86vw);
height: auto;
transform: translateX(-101%);
@ -150,7 +153,12 @@
.article-sections.open { transform: none; visibility: visible; }
/* The dark ground behind an open drawer. Tapping it closes the drawer. */
.article-drawer-backdrop {
position: fixed; inset: 0; z-index: 39; background: rgba(15, 23, 42, .38);
/* Below the header, not over it. Covering the header put a transparent
sheet across the very button that opened the drawer, so pressing it
again hit the backdrop instead the drawer closed, but the button
looked dead. */
position: fixed; top: var(--article-top); left: 0; right: 0; bottom: 0;
z-index: 1249; background: rgba(15, 23, 42, .38);
}
.article-rail-hide, .article-rail-reopen { display: none; }
/* No Contents button. The menu in the header opens the contents on a phone,

View file

@ -1558,8 +1558,12 @@ const timerStarted = timeLeft !== null
{/* The Review badge lives on the rail now, beside the name of
the session it belongs to. Two of them on one screen, saying
the same word about the same block, was one too many. */}
<span>Q {currentIdx + 1} / {totalCount}</span>
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} answered</span>
{/* No "Q 1 / 20" here. The bar above the question says where you
are, in words, a centimetre below this and on a phone the
two counters were most of the chrome between the header and
the case. How much is done is not said anywhere else, so it
stays. */}
<span style={{ color: 'var(--text-subtle)' }}>{answeredCount} of {totalCount} answered</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
@ -1759,15 +1763,20 @@ const timerStarted = timeLeft !== null
Except in an exam, where the chrome across the top is that
control and repeating it here is the same button twice. */}
{/* Every control in this bar names itself. On a phone the words
are hidden and only the glyph is left, so a button whose name
lived in that hidden span had no name at all to a screen
reader and no tooltip to a long press. */}
{!examChrome && (
<button type="button" className={labsOpen ? 'is-on' : ''}
aria-pressed={labsOpen} onClick={() => setLabsOpen(v => !v)}>
aria-pressed={labsOpen} aria-label="Labs" title="Lab values"
onClick={() => setLabsOpen(v => !v)}>
<span>Labs</span>
</button>
)}
{current.attending_tip && (
<button type="button" className={panel === 'tip' ? 'is-on' : ''}
aria-pressed={panel === 'tip'}
aria-pressed={panel === 'tip'} aria-label="Attending tip" title="Attending tip"
onClick={() => {
if (panel !== 'tip') noteHint()
setPanel(p => (p === 'tip' ? null : 'tip'))
@ -1777,6 +1786,8 @@ const timerStarted = timeLeft !== null
)}
<button type="button" className={panel === 'note' ? 'is-on' : ''}
aria-pressed={panel === 'note'}
aria-label={note ? 'Notes' : 'Add notes'}
title={note ? 'Your note on this question' : 'Add a note to this question'}
onClick={() => setPanel(p => (p === 'note' ? null : 'note'))}>
<span>{note ? 'Notes' : 'Add notes'}</span>
</button>
@ -1798,6 +1809,7 @@ const timerStarted = timeLeft !== null
</MoreActions>
<button type="button" className={favorites.includes(current.id) ? 'is-on' : ''}
onClick={() => toggleFavorite(current.id)}
aria-label={favorites.includes(current.id) ? 'Remove from favourites' : 'Add to favourites'}
title={favorites.includes(current.id)
? 'Remove from favourites' : 'Add to favourites — sit them as their own session later'}>
{favorites.includes(current.id) ? '★' : '☆'} <span>Favourite</span>
@ -1827,14 +1839,20 @@ const timerStarted = timeLeft !== null
{voices.length > 0 && !examChrome && (
<button type="button" className={readThrough ? 'is-on' : ''}
onClick={() => setReadThrough(v => !v)}
aria-label={readThrough ? 'Stop reading through' : 'Read each question aloud and move on'}
title="Read each question aloud and advance automatically">
<span>{readThrough ? 'Stop' : 'Listen through'}</span>
</button>
)}
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
{/* Not a pencil. The pencil beside it opens the note, and
two identical glyphs an inch apart doing different things
is how a thumb picks the wrong one. */}
<button type="button" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights}
disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
<span>Clear</span>
disabled={!manualHighlights[current.id]}
aria-label="Clear all highlights on this question"
title="Clear all highlights on this question">
<span>Clear</span>
</button>
</div>
</div>

View file

@ -297,7 +297,7 @@ describe('quiz player', () => {
// Nothing was answered by it: not in the rail, not in the count, and not
// in what is handed in.
expect(document.querySelectorAll('.quiz-rail-item')[0].className).not.toMatch(/is-done/)
expect(within(document.querySelector('.quiz-header-card')).getByText('0 answered')).toBeInTheDocument()
expect(within(document.querySelector('.quiz-header-card')).getByText('0 of 2 answered')).toBeInTheDocument()
// Nor can an option be picked afterwards that would be copying, not
// answering, and it would count.
await userEvent.click(inCard().getByText('Second answer').closest('.option'))

View file

@ -990,3 +990,17 @@ body:has(.quiz-player.is-exam-chrome) .site-footer { display: none; }
/* 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; }
/* The session's own header, on a phone
Gone. The title, the mode and how much is answered are all in the drawer
behind the menu button, and the bar above the question says which question
this is. On a 390px screen those two rows were a fifth of the height of the
scroller, repeated above every question. */
@media (max-width: 820px) {
.quiz-header-card { display: none; }
}
/* The last line of an explanation sat exactly on the bar at the foot of the
player, with nothing between the two. A scroller that ends flush against a
sticky bar reads as content cut off rather than content finished. */
.quiz-layout { padding-bottom: 14px; }