feat: rule options out, skip says skip, and the highlighter stops covering the words
Striking an option through is how anybody works a five-option question: eliminate, then choose among what is left. The control sits outside the option so that ruling one out is never mistaken for picking it, and it goes once the question is marked — there is nothing left to narrow down. Not saved: it is working-out, not an answer, and should not follow you into another sitting of the same question. Moving on from a question you have not answered is a decision, so the button says which one it is rather than calling both Next. The highlighter was a 72%-opacity band across the bottom 62% of every line — over the text rather than behind it. Descenders vanished into it, small type became hard to read, and a link inside a highlight lost the underline that said it was a link. It starts below the x-height now, is light enough to read through, keeps link underlines, and dims on a dark ground where the same yellow glared. Create Test goes straight into the session. You chose the topics, the count and the mode and pressed Create Test; an overview asking whether you meant it is a second confirmation of a decision already made twice. And the action bar has a layout of its own below 700px. With no rules it wrapped into whatever the flex produced — a label on its own line, two pills, then a Refresh and an outsized Create Test. Two rows: the mode toggle is the question, Create Test is the answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
e311a6b5ad
commit
816ab0a66c
7 changed files with 153 additions and 11 deletions
|
|
@ -545,13 +545,29 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
}
|
||||
.manual-highlight-segment { user-select: text; -webkit-user-select: text; touch-action: auto; }
|
||||
/* A marker stroke under the words, not a block across them.
|
||||
*
|
||||
* It was a 72%-opacity band covering the bottom 62% of the line, which sat
|
||||
* over the text rather than behind it: descenders disappeared into it, small
|
||||
* type became hard to read, and a link inside a highlight lost the underline
|
||||
* that said it was a link. The band now starts below the x-height and is
|
||||
* light enough to read through, which is what a highlighter actually does. */
|
||||
.manual-highlight-active {
|
||||
background: linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%);
|
||||
background: linear-gradient(transparent 58%, var(--marker, rgba(253, 224, 71, 0.55)) 58%);
|
||||
border-radius: 2px;
|
||||
cursor: context-menu;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
/* A cross-reference inside a highlight is still a cross-reference. The band
|
||||
used to swallow its underline, so the two were indistinguishable. */
|
||||
.manual-highlight-active a,
|
||||
.manual-highlight-active .al-link {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.speech-highlight-active {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
box-shadow: inset 0 -2px 0 rgba(59, 130, 246, 0.72);
|
||||
|
|
@ -562,10 +578,17 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|||
}
|
||||
.manual-highlight-active.speech-highlight-active {
|
||||
background:
|
||||
linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%),
|
||||
linear-gradient(transparent 58%, var(--marker, rgba(253, 224, 71, 0.55)) 58%),
|
||||
rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
|
||||
/* On a dark ground the same yellow glares. Dimmer, and warmer, so the text on
|
||||
top of it stays the brightest thing. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) { --marker: rgba(250, 204, 21, 0.32); }
|
||||
}
|
||||
:root[data-theme="dark"] { --marker: rgba(250, 204, 21, 0.32); }
|
||||
|
||||
/* Options */
|
||||
.question-card .options { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.question-card .option {
|
||||
|
|
|
|||
|
|
@ -295,3 +295,28 @@
|
|||
color: var(--text-muted); text-decoration: underline;
|
||||
}
|
||||
.facet-chosen-clear:hover { color: var(--wrong-fg); }
|
||||
|
||||
/* ── The action bar on a narrow screen ────────────────────────────────
|
||||
It had no rules of its own below the desktop width, so it wrapped into
|
||||
whatever order the flex happened to produce: a "Test type" label on its
|
||||
own line, two pills, then a Refresh and an outsized Create Test. Three
|
||||
rows of unrelated things.
|
||||
|
||||
Two rows instead. The mode toggle is the question — study or exam — so it
|
||||
takes a full row and splits it evenly. Create Test is the answer, so it
|
||||
takes the next one, with Refresh beside it at the width of its own words. */
|
||||
@media (max-width: 700px) {
|
||||
.custom-test-bar-inner {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px 0 calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
/* The pills say which is which; a label above them repeats it. */
|
||||
.custom-test-bar-label { display: none; }
|
||||
.custom-test-modes { grid-column: 1 / -1; }
|
||||
.custom-test-modes label { flex: 1; }
|
||||
.custom-test-modes span { text-align: center; padding: 10px 8px; }
|
||||
.custom-test-refresh { grid-column: 1; white-space: nowrap; }
|
||||
.custom-test-start { grid-column: 2; width: 100%; min-height: 44px; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ export default function CustomQuizPage() {
|
|||
is_shared: shared, difficulty: difficulty || null, algorithm: adaptive ? 'adaptive' : 'random',
|
||||
article_ids: articleIds, tag_ids: tagIds, system_ids: systemIds, explicit_ids: explicitIds,
|
||||
})
|
||||
navigate(`/study/${result.data.id}`)
|
||||
// Straight in. You chose the topics, the count and the mode and pressed
|
||||
// Create Test; an overview asking whether you meant it is a second
|
||||
// confirmation of a decision already made twice over.
|
||||
navigate(`/study/${result.data.id}?start=1`)
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
setError(typeof detail === 'string' ? detail : 'Could not create test. Check your settings and try again.')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { fireEvent, render, screen, waitFor , within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import api from '../api/client'
|
||||
import CustomQuizPage from './CustomQuizPage'
|
||||
|
|
@ -20,10 +20,16 @@ function setupCount(count = 30) {
|
|||
return Promise.resolve({ data: { count } })
|
||||
})
|
||||
}
|
||||
/** Reports where the builder landed, search string and all. */
|
||||
function LandedOn() {
|
||||
const [params] = useSearchParams()
|
||||
return <h1 data-testid="landed">Saved test {params.toString()}</h1>
|
||||
}
|
||||
|
||||
function renderBuilder() {
|
||||
render(<MemoryRouter initialEntries={['/study/new']}><Routes>
|
||||
<Route path="/study/new" element={<CustomQuizPage />} />
|
||||
<Route path="/study/:id" element={<h1>Saved test</h1>} />
|
||||
<Route path="/study/:id" element={<LandedOn />} />
|
||||
</Routes></MemoryRouter>)
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +73,11 @@ describe('CustomQuizPage', () => {
|
|||
mode: 'timed', time_limit_minutes: 15, expected_count: 30, is_shared: true,
|
||||
difficulty: null, algorithm: 'random', article_ids: [], tag_ids: [], system_ids: [], explicit_ids: [],
|
||||
})
|
||||
await screen.findByRole('heading', { name: 'Saved test' })
|
||||
await screen.findByRole('heading', { name: /Saved test/ })
|
||||
// Straight into it. You chose the topics, the count and the mode and
|
||||
// pressed Create Test; an overview asking whether you meant it is a
|
||||
// second confirmation of a decision already made twice.
|
||||
expect(screen.getByTestId('landed')).toHaveTextContent('start=1')
|
||||
})
|
||||
|
||||
it('blocks zero/insufficient pools and invalid counts', async () => {
|
||||
|
|
@ -98,7 +108,7 @@ describe('CustomQuizPage', () => {
|
|||
api.post.mockRejectedValue({ response: { data: { detail: 'Available count changed. Refresh the count and try again' } } })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create Test' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Available count changed')
|
||||
expect(screen.queryByRole('heading', { name: 'Saved test' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: /Saved test/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('summarises each facet as All, a name, or a name with +N', async () => {
|
||||
|
|
|
|||
|
|
@ -980,6 +980,23 @@ const timerStarted = timeLeft !== null
|
|||
setAnswer(current.id, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Options ruled out.
|
||||
*
|
||||
* Striking one through is how anybody actually works a five-option question:
|
||||
* eliminate, then choose among what is left. It is working-out rather than
|
||||
* an answer, so it lives in the page and is not saved — it should not follow
|
||||
* you into another sitting of the same question.
|
||||
*/
|
||||
const [ruledOut, setRuledOut] = useState({})
|
||||
const toggleRuledOut = (index) => setRuledOut(prev => {
|
||||
const forQuestion = new Set(prev[current.id] || [])
|
||||
if (forQuestion.has(index)) forQuestion.delete(index)
|
||||
else forQuestion.add(index)
|
||||
return { ...prev, [current.id]: [...forQuestion] }
|
||||
})
|
||||
const isRuledOut = (index) => (ruledOut[current?.id] || []).includes(index)
|
||||
|
||||
// Free text is the exception: clicking an option is a decision, typing is
|
||||
// not, so a typed answer is held until Enter or leaving the field.
|
||||
const [typed, setTyped] = useState('')
|
||||
|
|
@ -1214,7 +1231,13 @@ const timerStarted = timeLeft !== null
|
|||
{isStudy ? 'Review & Complete' : 'End block'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next →</button>
|
||||
/* Skip, when nothing has been chosen. Moving on from a question you
|
||||
have not answered is a decision, and the button should say which
|
||||
decision it is rather than calling both of them Next. */
|
||||
<button className="btn btn-primary"
|
||||
onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>
|
||||
{answers[current?.id] ? 'Next →' : 'Skip →'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1613,8 +1636,9 @@ const timerStarted = timeLeft !== null
|
|||
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
|
||||
const optionFieldKey = `option-${i}`
|
||||
return (
|
||||
<button type="button" key={i} aria-pressed={isSelected} aria-disabled={hasAnswered}
|
||||
className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
|
||||
<div key={i} className="option-row">
|
||||
<button type="button" aria-pressed={isSelected} aria-disabled={hasAnswered}
|
||||
className={`option ${isSelected ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''} ${isRuledOut(i) ? 'ruled-out' : ''}`}
|
||||
onClick={() => {
|
||||
if (hasActiveTextSelection()) return
|
||||
if (!hasAnswered) return chooseAnswer(opt)
|
||||
|
|
@ -1652,6 +1676,16 @@ const timerStarted = timeLeft !== null
|
|||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Outside the option, so ruling one out is never
|
||||
mistaken for choosing it. Gone once the question is
|
||||
marked — there is nothing left to narrow down. */}
|
||||
{!hasAnswered && (
|
||||
<button type="button" className="option-rule-out"
|
||||
aria-pressed={isRuledOut(i)}
|
||||
aria-label={`${isRuledOut(i) ? 'Bring back' : 'Rule out'} option ${letter}`}
|
||||
onClick={() => toggleRuledOut(i)}>ab</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -251,6 +251,29 @@ describe('quiz player', () => {
|
|||
expect(await screen.findByText('Submitted results')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rules an option out without choosing it', async () => {
|
||||
await begin(false)
|
||||
await findStem('Full first clinical question.')
|
||||
// Eliminate, then choose among what is left. The control is outside the
|
||||
// option so that striking one through cannot be mistaken for picking it.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Rule out option 1' }))
|
||||
expect(screen.getByRole('button', { name: 'Bring back option 1' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(document.querySelector('.option.ruled-out')).toBeInTheDocument()
|
||||
// Nothing was answered by it.
|
||||
expect(document.querySelector('.option.selected')).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Bring back option 1' }))
|
||||
expect(document.querySelector('.option.ruled-out')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the rule-out control once the question is marked', async () => {
|
||||
await begin()
|
||||
fireEvent.keyDown(window, { key: '1' })
|
||||
await screen.findByText(/Full explanation, preserved without shortening/)
|
||||
// There is nothing left to narrow down.
|
||||
expect(screen.queryByRole('button', { name: /Rule out option/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('marks a study answer the moment it is chosen, and shows genuine response data', async () => {
|
||||
await begin()
|
||||
// Choosing is answering. It used to hold the choice and wait for "Submit
|
||||
|
|
@ -317,7 +340,8 @@ describe('quiz player', () => {
|
|||
expect(bar).toBeInTheDocument()
|
||||
expect(bar.closest('.quiz-layout')).toBeNull()
|
||||
expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument()
|
||||
expect(within(bar).getByRole('button', { name: /Next/ })).toBeInTheDocument()
|
||||
// Nothing answered yet, so moving on is a skip and the button says so.
|
||||
expect(within(bar).getByRole('button', { name: /Skip/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps notes with the question, not in a second notepad floating over it', async () => {
|
||||
|
|
|
|||
|
|
@ -348,3 +348,26 @@ body:has(.quiz-player.is-boxed) .site-footer { display: none; }
|
|||
}
|
||||
.quiz-away-card h2 { margin: 0 0 10px; font-size: 1.15rem; }
|
||||
.quiz-away-card p { margin: 0 0 20px; font-size: 0.9rem; line-height: 1.6; color: var(--text-muted); }
|
||||
|
||||
/* ── Ruling an option out ─────────────────────────────────────────────
|
||||
Eliminate, then choose among what is left — which is how anybody actually
|
||||
works a five-option question. The control sits outside the option so that
|
||||
striking one through is never mistaken for choosing it. */
|
||||
.option-row { display: flex; align-items: stretch; gap: 6px; }
|
||||
.option-row > .option { flex: 1; min-width: 0; }
|
||||
|
||||
.option.ruled-out .option-text { opacity: 0.4; text-decoration: line-through; }
|
||||
.option.ruled-out .option-letter { opacity: 0.4; }
|
||||
|
||||
.option-rule-out {
|
||||
flex: none; align-self: flex-start;
|
||||
width: 34px; min-height: 34px; margin-top: 4px;
|
||||
font: inherit; font-size: 0.78rem; font-weight: 700; font-style: italic;
|
||||
cursor: pointer; color: var(--text-subtle);
|
||||
background: none; border: 1px solid transparent; border-radius: 8px;
|
||||
}
|
||||
.option-rule-out:hover { border-color: var(--border); color: var(--text-muted); }
|
||||
.option-rule-out[aria-pressed="true"] {
|
||||
text-decoration: line-through;
|
||||
color: var(--wrong-fg); border-color: var(--wrong-bd); background: var(--wrong-bg);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue