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:
parent
b345709c4b
commit
c1391c1554
7 changed files with 74 additions and 13 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue