From 1f2d0a81a1d0670c17cd706c3e998631391473ec Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 19:08:20 +0200 Subject: [PATCH] fix: two-card builder layout with a sticky mode and Start bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the AMBOSS screenshot: "Set session topics" and "Session criteria" sit side by side, the question count lives at the bottom of the criteria card as "20 /64", and session type plus Start are a sticky bar at the foot of the viewport so neither is behind a scroll. - Two-column grid, stacking to one column under 900px. - Facet rows now lead with a + affordance and show the selection as a chip ("Cardiology" "+1"); an unset facet stays plain "All". - Reset moved to the top right with its ↺ icon. - Adaptive session is its own row inside the criteria card, with the spark mark and toggle on the right. - A More expander holds what AMBOSS does not have — sharing, the exam time limit, and the unused/incorrect explanation — instead of stacking them. - Mode is a segmented control in the bar rather than radio cards in the flow. Tests: 2 new frontend tests pinning the count to the criteria card and mode plus Create Test to the sticky bar, and the time limit appearing under More only in exam mode. Full suites green: 88 backend, 122 frontend, build clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA --- frontend/src/components/FacetPicker.jsx | 15 +- frontend/src/pages/CustomQuizPage.css | 162 ++++++++++++---- frontend/src/pages/CustomQuizPage.jsx | 206 ++++++++++++--------- frontend/src/pages/CustomQuizPage.test.jsx | 29 +++ 4 files changed, 285 insertions(+), 127 deletions(-) diff --git a/frontend/src/components/FacetPicker.jsx b/frontend/src/components/FacetPicker.jsx index 32cffeb..2eb25f0 100644 --- a/frontend/src/components/FacetPicker.jsx +++ b/frontend/src/components/FacetPicker.jsx @@ -1,15 +1,20 @@ import { useEffect, useState } from 'react' -/** Collapsed facet row: label on the left, current selection on the right. */ -export function FacetRow({ label, summary, extra, onOpen }) { +/** + * Collapsed facet row: a + affordance, the label, and the current selection on + * the right — plain "All" when nothing is picked, a chip (plus a +N chip) once + * something is. + */ +export function FacetRow({ label, summary, extra, chip, onOpen }) { + const selected = chip ?? summary !== 'All' return ( ) } diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 11df2c6..18fe683 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -1,71 +1,159 @@ /* Custom test builder — AMBOSS-style facet rows that open a picker panel, instead of a cramped sidebar of nested scrolling checkbox lists. */ -.custom-test { max-width: 720px; margin: 0 auto; padding-bottom: 40px; } -.custom-test-back { font-size: 0.85rem; color: var(--primary); text-decoration: none; } -.custom-test h1 { margin: 10px 0 4px; font-size: 1.5rem; } -.custom-test-intro { margin: 0 0 20px; color: var(--text-muted); font-size: 0.9rem; } +.custom-test { max-width: 1120px; margin: 0 auto; padding-bottom: 100px; } -/* ── Section headings ─────────────────────────────────────────────── */ -.custom-test-section { margin-bottom: 22px; } -.custom-test-section-head { - display: flex; align-items: center; gap: 12px; margin: 0 0 10px; +.custom-test-top { + display: grid; grid-template-columns: 1fr auto; align-items: end; + gap: 6px 16px; margin-bottom: 16px; } -.custom-test-section-head h2 { margin: 0; font-size: 1rem; font-weight: 650; white-space: nowrap; } -.custom-test-section-head::after { content: ''; flex: 1; height: 1px; background: var(--border); } +.custom-test-back { grid-column: 1 / -1; font-size: 0.85rem; color: var(--primary); text-decoration: none; } +.custom-test-top h1 { margin: 8px 0 4px; font-size: 1.5rem; } +.custom-test-intro { margin: 0; color: var(--text-muted); font-size: 0.9rem; } .custom-test-reset { - background: none; border: none; padding: 0; cursor: pointer; flex-shrink: 0; + display: inline-flex; align-items: center; gap: 6px; align-self: end; + background: none; border: none; padding: 4px; cursor: pointer; font: inherit; font-size: 0.74rem; font-weight: 700; letter-spacing: 0.06em; - text-transform: uppercase; color: var(--primary); + text-transform: uppercase; color: var(--text-muted); white-space: nowrap; } +.custom-test-reset:hover { color: var(--primary); } + +/* ── Two cards side by side ───────────────────────────────────────── */ +.custom-test-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; } +.custom-test-card { + background: var(--card-bg); border: 1px solid var(--border); + border-radius: 12px; overflow: hidden; +} +.custom-test-card > h2 { + margin: 0; padding: 16px 18px; font-size: 1.05rem; font-weight: 650; + border-bottom: 1px solid var(--border); +} +.custom-test-card-pad { padding: 14px 18px; } .custom-test-label { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); margin: 0 0 6px; } /* ── Global facet search ──────────────────────────────────────────── */ -.custom-test-search { position: relative; margin-bottom: 12px; } +.custom-test-search { position: relative; } .custom-test-search input { width: 100%; padding: 10px 13px 10px 34px; font-size: 0.9rem; border: 1px solid var(--border); border-radius: 8px; - background: var(--input-bg); color: var(--text); + background: var(--bg); color: var(--text); } .custom-test-search-icon { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--text-subtle); } -.custom-test-hits { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 12px; } +.custom-test-hits { border-top: 1px solid var(--border); } .custom-test-hits label { - display: flex; align-items: center; gap: 10px; padding: 10px 13px; + display: flex; align-items: center; gap: 10px; padding: 10px 18px; font-size: 0.88rem; cursor: pointer; border-bottom: 1px solid var(--border); } -.custom-test-hits label:last-child { border-bottom: 0; } .custom-test-hits label:hover { background: var(--bg); } .custom-test-hit-facet { margin-left: auto; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--text-subtle); } -.custom-test-hits-empty { padding: 14px; color: var(--text-muted); font-size: 0.86rem; } +.custom-test-hits-empty { padding: 12px 18px; color: var(--text-muted); font-size: 0.86rem; margin: 0; border-top: 1px solid var(--border); } /* ── Facet rows ───────────────────────────────────────────────────── */ -.facet-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--card-bg); } +.facet-list { border-top: 1px solid var(--border); } .facet-row { display: flex; align-items: center; gap: 12px; width: 100%; - padding: 13px 15px; background: none; border: none; border-bottom: 1px solid var(--border); + padding: 14px 18px; background: none; border: none; border-bottom: 1px solid var(--border); font: inherit; text-align: left; cursor: pointer; color: var(--text); } .facet-row:last-child { border-bottom: 0; } .facet-row:hover { background: var(--bg); } -.facet-row-label { font-size: 0.9rem; font-weight: 600; white-space: nowrap; } +.facet-row-icon { color: var(--text-subtle); font-size: 1.15rem; line-height: 1; flex-shrink: 0; width: 14px; } +.facet-row:hover .facet-row-icon { color: var(--primary); } +.facet-row-label { font-size: 0.95rem; } .facet-row-summary { margin-left: auto; display: flex; align-items: center; gap: 6px; min-width: 0; } -.facet-row-value { - font-size: 0.86rem; color: var(--text-muted); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46vw; +.facet-row-value { font-size: 0.9rem; color: var(--text-muted); white-space: nowrap; } +.facet-chip { + font-size: 0.82rem; border-radius: 6px; padding: 3px 9px; + background: var(--option-sel-bg); color: var(--primary); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; max-width: 190px; } -.facet-row-more { - flex-shrink: 0; font-size: 0.72rem; font-weight: 700; - background: var(--primary); color: var(--primary-fg); border-radius: 20px; padding: 1px 8px; -} -.facet-row-chevron { color: var(--text-subtle); font-size: 1.1rem; line-height: 1; flex-shrink: 0; } +.facet-chip.is-more { flex-shrink: 0; font-weight: 700; } .facet-row.is-fixed { cursor: default; } .facet-row.is-fixed:hover { background: none; } +.facet-row.is-fixed .facet-row-icon { color: var(--text-subtle); } + +/* ── Criteria ─────────────────────────────────────────────────────── */ +.custom-test-field { display: block; } +.custom-test-field > span { display: block; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle); margin-bottom: 6px; } +.custom-test-field input { + width: 100%; padding: 10px 13px; font-size: 0.95rem; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.custom-test-adaptive { + display: flex; align-items: center; gap: 12px; + padding: 14px 18px; border-top: 1px solid var(--border); +} +.custom-test-adaptive > div { flex: 1; min-width: 0; } +.custom-test-adaptive strong { display: block; font-size: 0.98rem; font-weight: 650; color: #6d28d9; } +.custom-test-adaptive p { margin: 2px 0 0; font-size: 0.82rem; color: var(--text-muted); } +.custom-test-spark { margin-right: 4px; } +.custom-test-toggle { display: inline-flex; align-items: center; cursor: pointer; flex-shrink: 0; } +.custom-test-toggle input { position: absolute; opacity: 0; width: 0; height: 0; } +.custom-test-switch { + width: 40px; height: 22px; border-radius: 999px; background: var(--border); + position: relative; flex-shrink: 0; transition: background 0.15s; +} +.custom-test-switch > span { + position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; + border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 0.15s; +} +.custom-test-toggle input:checked + .custom-test-switch { background: #7c3aed; } +.custom-test-toggle input:checked + .custom-test-switch > span { transform: translateX(18px); } +.custom-test-toggle input:focus-visible + .custom-test-switch { outline: 2px solid var(--primary); outline-offset: 2px; } + +.custom-test-more { + display: flex; align-items: center; gap: 6px; width: 100%; + padding: 12px 18px; background: none; border: none; border-top: 1px solid var(--border); + font: inherit; font-size: 0.76rem; font-weight: 700; letter-spacing: 0.06em; + text-transform: uppercase; color: var(--text-muted); cursor: pointer; +} +.custom-test-more:hover { color: var(--primary); } +.custom-test-check { display: flex; align-items: center; gap: 9px; font-size: 0.88rem; cursor: pointer; } + +.custom-test-countbox { padding: 14px 18px; border-top: 1px solid var(--border); } +.custom-test-count-row { display: flex; align-items: center; gap: 10px; } +.custom-test-count-row input { + width: 92px; padding: 10px 12px; font-size: 1rem; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.custom-test-count-of { color: var(--text-muted); font-size: 1rem; } +.custom-test-count-note { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-subtle); } +.custom-test-note { color: var(--text-muted); font-size: 0.8rem; line-height: 1.6; margin: 8px 0 0; } +.custom-test-error { color: var(--wrong-fg); font-size: 0.84rem; margin: 8px 0 0; } + +/* ── Sticky bottom bar: mode and Start stay reachable without scrolling ── */ +.custom-test-bar { + grid-column: 1 / -1; + position: sticky; bottom: 0; z-index: 40; + margin: 4px -16px -100px; padding: 0 16px; + background: var(--card-bg); border-top: 1px solid var(--border); + box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06); +} +.custom-test-bar-inner { + max-width: 1120px; margin: 0 auto; + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 12px 0 calc(12px + env(safe-area-inset-bottom)); +} +.custom-test-bar-label { margin: 0; } +.custom-test-modes { display: flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.custom-test-modes label { cursor: pointer; } +.custom-test-modes input { position: absolute; opacity: 0; width: 0; height: 0; } +.custom-test-modes span { + display: block; padding: 9px 18px; font-size: 0.9rem; font-weight: 600; + color: var(--text-muted); background: var(--card-bg); white-space: nowrap; +} +.custom-test-modes label + label span { border-left: 1px solid var(--border); } +.custom-test-modes input:checked + span { background: var(--option-sel-bg); color: var(--primary); } +.custom-test-modes input:focus-visible + span { outline: 2px solid var(--primary); outline-offset: -2px; } +.custom-test-start { margin-left: auto; min-width: 150px; } /* ── Picker panel ─────────────────────────────────────────────────── */ .facet-overlay { @@ -153,10 +241,20 @@ .custom-test-actions { display: flex; gap: 8px; margin-top: 18px; } .custom-test-actions .btn:last-child { flex: 1; } +@media (max-width: 900px) { + .custom-test-grid { grid-template-columns: 1fr; } +} @media (max-width: 640px) { - .custom-test-modes label { min-width: 100%; } + .custom-test { padding-bottom: 120px; } + .custom-test-top { grid-template-columns: 1fr; } + .custom-test-reset { justify-self: start; padding-left: 0; } .facet-overlay { align-items: flex-end; } .facet-panel { width: 100%; height: 88vh; border-radius: 16px 16px 0 0; } - .facet-row-value { max-width: 40vw; } - .custom-test-actions { flex-direction: column; } + .facet-chip { max-width: 40vw; } + .custom-test-bar-label { display: none; } + .custom-test-bar-inner { padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); } + .custom-test-modes { flex: 1; } + .custom-test-modes label { flex: 1; } + .custom-test-modes span { text-align: center; padding: 9px 10px; } + .custom-test-start { width: 100%; margin-left: 0; } } diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index ca4fc74..d5a65c2 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -49,6 +49,7 @@ export default function CustomQuizPage() { const [collections, setCollections] = useState([]) const [openFacet, setOpenFacet] = useState(null) const [globalSearch, setGlobalSearch] = useState('') + const [moreOpen, setMoreOpen] = useState(false) const filterKey = JSON.stringify([categoryIds, state, shared, difficulty, articleIds, tagIds, refresh]) @@ -211,28 +212,34 @@ export default function CustomQuizPage() { return (
- ← Quizzes -

Create Custom Test

-

Choose questions from your bank, {user?.name || 'learner'}.

+
+ ← Quizzes +
+

Create Custom Test

+

Choose questions from your bank, {user?.name || 'learner'}.

+
+ +
-
- {/* ── Topics ─────────────────────────────────────────────── */} -
-
-

Set test topics

- -
+ + {/* ── Topics ───────────────────────────────────────────────── */} +
+

Set test topics

- Filter search -
- - setGlobalSearch(e.target.value)} - placeholder="E.g. systems, disciplines, keywords" aria-label="Filter search" /> +
+ Filter search +
+ + setGlobalSearch(e.target.value)} + placeholder="E.g. systems, disciplines, keywords" aria-label="Filter search" /> +
{hits !== null && ( hits.length === 0 - ?

Nothing matches “{globalSearch}”.

+ ?

Nothing matches “{globalSearch}”.

: (
{hits.map(hit => ( @@ -248,100 +255,119 @@ export default function CustomQuizPage() {
+ Exams - Pediatrics Boards + Pediatrics Boards
setOpenFacet('systems')} /> setOpenFacet('disciplines')} /> - setOpenFacet('symptoms')} /> + setOpenFacet('symptoms')} /> setOpenFacet('articles')} /> - setOpenFacet('saved')} /> + setOpenFacet('saved')} />
- {/* ── Criteria ───────────────────────────────────────────── */} -
-

Test criteria

+ {/* ── Criteria ─────────────────────────────────────────────── */} +
+

Test criteria

- +
+ +
- +
+
+ Adaptive session +

Questions prioritized by impact

+
+ +
{adaptive && ( -

- Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older - incorrect ones, and moves between weak areas instead of repeating one. +

+ Adaptive prefers unanswered questions, then recycles older incorrect ones, and moves between weak + areas instead of repeating one.

)} -
- setOpenFacet('difficulty')} /> - setOpenFacet('status')} /> +
+ setOpenFacet('difficulty')} /> + setOpenFacet('status')} />
- -

- Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, - including skipped questions. -

-
- - {/* ── Count and type ─────────────────────────────────────── */} -
- Question count -
- setCount(e.target.value)} /> - - {ready ? `${available} questions available` : 'Counting available questions…'} - -
- {ready && available === 0 &&

No questions match these filters.

} - {ready && !validCount && available > 0 &&

Choose 1–{Math.min(200, available)} questions.

} - {countError &&

{countError}

} -
- -
- Test type -
- - -
- {mode === 'timed' && ( - - )} -
- -
- - + {moreOpen && ( +
+ + {mode === 'timed' && ( + + )} +

+ Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, + including skipped questions. +

+
+ )} + +
+ Question count +
+ setCount(e.target.value)} /> + + {ready ? `/${available}` : 'Counting…'} + +
+

+ {ready ? `${available} questions available` : 'Counting available questions…'} +

+ {ready && available === 0 &&

No questions match these filters.

} + {ready && !validCount && available > 0 &&

Choose 1–{Math.min(200, available)} questions.

} + {countError &&

{countError}

} +
+
+ + {/* ── Sticky action bar ────────────────────────────────────── */} +
+
+ Test type +
+ + +
+ + +
{error &&

{error}

} - {/* ── Facet pickers ────────────────────────────────────────── */} setOpenFacet(null)} onReset={() => setCategoryIds([])} diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index 6723fe8..c493dc6 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -35,6 +35,9 @@ describe('CustomQuizPage', () => { renderBuilder() await screen.findByText('30 questions available') expect(screen.getByRole('radio', { name: 'Study mode' })).toBeChecked() + // Sharing is our own addition to AMBOSS's criteria, so it lives under More. + expect(screen.queryByLabelText(/Share with/)).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /^More/ })) expect(screen.getByLabelText(/Share with/)).not.toBeChecked() expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument() @@ -157,6 +160,32 @@ describe('CustomQuizPage', () => { expect(screen.getByRole('button', { name: /^Systems/ })).toHaveTextContent('All') }) + it('keeps the count in the criteria card and mode plus Start in a sticky bar', async () => { + renderBuilder() + await screen.findByText('30 questions available') + + // Question count sits inside the criteria card, shown as "20 /30". + const countInput = screen.getByLabelText('Number of questions') + expect(countInput.closest('.custom-test-card')).toContainElement(screen.getByRole('heading', { name: 'Test criteria' })) + expect(screen.getByText('/30')).toBeInTheDocument() + + // Mode and the submit button live in the sticky bar, not in the cards. + const bar = document.querySelector('.custom-test-bar') + expect(bar).toBeInTheDocument() + expect(bar).toContainElement(screen.getByRole('radio', { name: 'Study mode' })) + expect(bar).toContainElement(screen.getByRole('radio', { name: 'Exam mode' })) + expect(bar).toContainElement(screen.getByRole('button', { name: 'Create Test' })) + }) + + it('reveals the time limit under More once exam mode is picked', async () => { + renderBuilder() + await screen.findByText('30 questions available') + await userEvent.click(screen.getByRole('button', { name: /^More/ })) + expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('radio', { name: 'Exam mode' })) + expect(screen.getByLabelText(/Time limit/)).toBeInTheDocument() + }) + it('ignores outdated count responses after filters change', async () => { let resolveOld api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: categories }) : new Promise(resolve => { resolveOld = resolve }))