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