diff --git a/frontend/src/index.css b/frontend/src/index.css index 7875fea..c65e3ac 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 7c06f3f..3591aa2 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -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; } +} diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index 3e80392..eeefb78 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -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.') diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index a5034e6..f07f8ae 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -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

Saved test {params.toString()}

+} + function renderBuilder() { render( } /> - Saved test} /> + } /> ) } @@ -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 () => { diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 92f8ab8..5d10b58 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -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'} ) : ( - + /* 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. */ + )} ) @@ -1613,8 +1636,9 @@ const timerStarted = timeLeft !== null const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk) const optionFieldKey = `option-${i}` return ( - + {/* 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 && ( + + )} + ) })} diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index 1f93b13..3f98e49 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -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 () => { diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index eb279b1..b985eb2 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -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); +}