feat: a real objective picker, and a section bar that fits
The study objective was a <select> capped at 220px, so it clipped "Pediatrics Boards (2948)" mid-word, and it offered "USMLE Step 2 CK (0)" as though it were ready — choosing it would have scoped the bank to nothing. It is a dialog now: grouped by family, searchable, each option saying how many questions stand behind it, an objective with none shown but not selectable, and the change confirmed rather than applied the instant the pointer crosses an option. The section bar outran its width because four of its entries were not about the material. Home is the signed-out landing page and is gone from it; Dashboard, Account, Settings and signing out are about the person and now sit behind one account control. Nine entries instead of thirteen, which is the actual fix — the arrows added earlier make a long bar usable, they do not make it right. Frontend 320/320. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
73e9de8831
commit
522af7181c
7 changed files with 340 additions and 45 deletions
10
docs/TODO.md
10
docs/TODO.md
|
|
@ -233,6 +233,16 @@ Captured so nothing is lost while the article writing runs.
|
|||
|
||||
## Loose ends
|
||||
|
||||
- [ ] **Image thumbnails and caching** — you mentioned a tool from the ped-ai
|
||||
work that generates thumbnails and caches through Caddy so images load
|
||||
fast, click to open full size, same bucket, and no straightforward
|
||||
download of the original. Not started: I need the name of that tool or a
|
||||
pointer to it before wiring anything, and "make originals hard to pull"
|
||||
is a decision about how far to go (a signed short-lived URL per view is
|
||||
the honest version; watermarking and right-click blocking are not).
|
||||
MediaAsset already stores one path per image, so a `thumb_path` beside it
|
||||
plus a Caddy cache rule is the shape.
|
||||
|
||||
- [ ] **QuestionEditModal is dead code** — nothing has imported it since Edit
|
||||
moved to the full page. Its sibling CreateQuestionModal was removed when
|
||||
its last two callers were replaced; this one was already unreferenced, so
|
||||
|
|
|
|||
88
frontend/src/components/ExamSwitcher.css
Normal file
88
frontend/src/components/ExamSwitcher.css
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* The current study objective: a button that says what it is, and a dialog
|
||||
that lets you change it. */
|
||||
|
||||
.exam-switcher-button {
|
||||
display: inline-flex; align-items: baseline; gap: 6px; flex-shrink: 0;
|
||||
max-width: 280px; padding: 5px 11px;
|
||||
font: inherit; font-size: 0.8rem; cursor: pointer;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
.exam-switcher-button:hover { border-color: var(--primary); }
|
||||
.exam-switcher-label {
|
||||
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.07em;
|
||||
text-transform: uppercase; color: var(--text-subtle); white-space: nowrap;
|
||||
}
|
||||
.exam-switcher-name {
|
||||
font-weight: 650; min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 900px) { .exam-switcher-label { display: none; } }
|
||||
|
||||
/* ── The dialog ───────────────────────────────────────────────────── */
|
||||
.exo-overlay {
|
||||
position: fixed; inset: 0; z-index: 1100; display: flex;
|
||||
align-items: center; justify-content: center; padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
.exo {
|
||||
display: flex; flex-direction: column;
|
||||
width: min(920px, 100%); max-height: min(86vh, 780px);
|
||||
background: var(--card-bg); border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
.exo-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 24px 0; }
|
||||
.exo-head h2 { margin: 0; font-size: 1.25rem; font-weight: 700; }
|
||||
.exo-head button {
|
||||
width: 34px; height: 34px; border: 0; border-radius: 50%;
|
||||
background: none; cursor: pointer; color: var(--text-muted); font-size: 1.05rem;
|
||||
}
|
||||
.exo-head button:hover { background: var(--bg); color: var(--text); }
|
||||
.exo-lead { margin: 6px 24px 0; font-size: 0.86rem; line-height: 1.6; color: var(--text-muted); max-width: 68ch; }
|
||||
|
||||
.exo-search {
|
||||
margin: 14px 24px 0; padding: 9px 12px;
|
||||
/* 16px on touch so iOS does not zoom the page in on focus. */
|
||||
font-size: 16px; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 700px) { .exo-search { font-size: 0.88rem; } }
|
||||
|
||||
.exo-body { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 24px 4px; }
|
||||
.exo-family {
|
||||
margin: 18px 0 8px; padding: 8px 12px;
|
||||
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase;
|
||||
color: var(--text-muted); background: var(--bg); border-radius: 8px;
|
||||
}
|
||||
.exo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||||
|
||||
.exo-option {
|
||||
display: flex; align-items: flex-start; gap: 11px; padding: 13px 15px;
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.exo-option:hover { border-color: var(--primary); }
|
||||
.exo-option.is-on { border-color: var(--primary); background: var(--option-sel-bg); }
|
||||
.exo-option input { width: 18px; height: 18px; margin-top: 1px; flex-shrink: 0; }
|
||||
.exo-option strong { display: block; font-size: 0.92rem; font-weight: 650; }
|
||||
.exo-option small { display: block; margin-top: 2px; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.exo-option.is-all { margin-top: 8px; }
|
||||
/* Shown, so an educator can see it exists; not selectable, because choosing it
|
||||
would scope the bank to nothing. */
|
||||
.exo-option.is-empty { opacity: 0.55; cursor: not-allowed; }
|
||||
.exo-option.is-empty small { color: var(--wrong-fg); }
|
||||
|
||||
.exo-empty { padding: 24px 0; font-size: 0.88rem; color: var(--text-muted); }
|
||||
.exo-error { margin: 0 24px; font-size: 0.85rem; color: var(--wrong-fg); }
|
||||
.exo-foot {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 16px 24px calc(20px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.exo { max-height: 100%; height: 100%; border-radius: 0; }
|
||||
.exo-overlay { padding: 0; }
|
||||
.exo-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
|
@ -1,44 +1,144 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import './ExamSwitcher.css'
|
||||
|
||||
/**
|
||||
* Which exam the learner is studying for. The choice is stored on the user, so
|
||||
* it follows them between devices rather than living in this browser.
|
||||
* What the learner is revising for.
|
||||
*
|
||||
* It was a `<select>` in the header, which had to be capped at 220px and so
|
||||
* clipped "Pediatrics Boards (2948)" mid-word, and which offered objectives
|
||||
* with no questions behind them as though they were ready to use.
|
||||
*
|
||||
* Now it is what it actually is: a choice between a dozen exams, grouped by
|
||||
* the family they belong to, searchable, with what each one covers stated —
|
||||
* and confirmed rather than applied the instant the pointer passes over an
|
||||
* option. The choice is stored on the user, so it follows them between
|
||||
* devices rather than living in this browser.
|
||||
*/
|
||||
export default function ExamSwitcher({ onChange }) {
|
||||
const [exams, setExams] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [chosen, setChosen] = useState(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const search = useRef(null)
|
||||
|
||||
const load = () => api.get('/exams/')
|
||||
.then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) })
|
||||
.catch(() => setExams([]))
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/exams/')
|
||||
.then(res => { setExams(res.data.exams || []); setActiveId(res.data.active_exam_id ?? null) })
|
||||
.catch(() => setExams([]))
|
||||
}, [])
|
||||
if (!open) return undefined
|
||||
setChosen(activeId)
|
||||
setQuery('')
|
||||
setError('')
|
||||
search.current?.focus()
|
||||
const onKey = e => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [open, activeId])
|
||||
|
||||
const choose = async (value) => {
|
||||
const examId = value === '' ? null : Number(value)
|
||||
setBusy(true)
|
||||
// Grouped by family, in the order the server sorted them.
|
||||
const families = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
const groups = new Map()
|
||||
for (const exam of exams) {
|
||||
if (needle && !`${exam.name} ${exam.family}`.toLowerCase().includes(needle)) continue
|
||||
const key = exam.family || 'Other'
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key).push(exam)
|
||||
}
|
||||
return [...groups.entries()]
|
||||
}, [exams, query])
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.put('/exams/active', { exam_id: examId })
|
||||
setActiveId(examId)
|
||||
onChange?.(examId)
|
||||
} catch { /* leave the previous selection showing */ }
|
||||
await api.put('/exams/active', { exam_id: chosen })
|
||||
setActiveId(chosen)
|
||||
setOpen(false)
|
||||
onChange?.(chosen)
|
||||
} catch { setError('Could not change your study objective') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (exams.length === 0) return null
|
||||
const active = exams.find(e => e.id === activeId)
|
||||
|
||||
return (
|
||||
<label className="exam-switcher">
|
||||
<span className="sr-only">Studying for</span>
|
||||
<select value={activeId ?? ''} disabled={busy} aria-label="Studying for"
|
||||
onChange={e => choose(e.target.value)}>
|
||||
<option value="">All content</option>
|
||||
{exams.map(exam => (
|
||||
<option key={exam.id} value={exam.id}>{exam.name} ({exam.question_count})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<>
|
||||
<button type="button" className="exam-switcher-button" onClick={() => setOpen(true)}>
|
||||
<span className="exam-switcher-label">Studying for</span>
|
||||
<span className="exam-switcher-name">{active ? active.name : 'All content'}</span>
|
||||
<span aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="exo-overlay" onClick={e => e.target === e.currentTarget && setOpen(false)}>
|
||||
<div className="exo" role="dialog" aria-modal="true" aria-labelledby="exo-heading">
|
||||
<div className="exo-head">
|
||||
<h2 id="exo-heading">Current study objective</h2>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<p className="exo-lead">
|
||||
Scopes the question bank, the filters and your performance analysis.
|
||||
Material linked to no exam stays visible whichever you pick.
|
||||
</p>
|
||||
|
||||
<input ref={search} className="exo-search" type="search" value={query}
|
||||
placeholder="Search" aria-label="Search study objectives"
|
||||
onChange={e => setQuery(e.target.value)} />
|
||||
|
||||
<div className="exo-body" role="radiogroup" aria-labelledby="exo-heading">
|
||||
<label className={`exo-option is-all${chosen === null ? ' is-on' : ''}`}>
|
||||
<input type="radio" name="objective" checked={chosen === null}
|
||||
onChange={() => setChosen(null)} />
|
||||
<span>
|
||||
<strong>All content</strong>
|
||||
<small>Every question and article, unscoped.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{families.map(([family, list]) => (
|
||||
<section key={family}>
|
||||
<h3 className="exo-family">{family}</h3>
|
||||
<div className="exo-grid">
|
||||
{list.map(exam => {
|
||||
// An objective with nothing behind it would empty the
|
||||
// bank. Shown, so an educator can see it exists, but not
|
||||
// selectable until it has questions.
|
||||
const empty = exam.question_count === 0
|
||||
return (
|
||||
<label key={exam.id}
|
||||
className={`exo-option${chosen === exam.id ? ' is-on' : ''}${empty ? ' is-empty' : ''}`}>
|
||||
<input type="radio" name="objective" disabled={empty}
|
||||
checked={chosen === exam.id} onChange={() => setChosen(exam.id)} />
|
||||
<span>
|
||||
<strong>{exam.name}</strong>
|
||||
<small>{empty ? 'No questions yet' : `${exam.question_count} questions`}</small>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{families.length === 0 && <p className="exo-empty">Nothing matches that.</p>}
|
||||
</div>
|
||||
|
||||
{error && <p className="exo-error" role="alert">{error}</p>}
|
||||
<div className="exo-foot">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy || chosen === activeId}
|
||||
onClick={save}>{busy ? 'Saving…' : 'Save changes'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import ScrollStrip from './ScrollStrip'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
|
@ -61,6 +61,56 @@ function JobsBadge({ jobs }) {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* You, rather than the material: dashboard, account, settings, sign out.
|
||||
*
|
||||
* These were four more entries in the section bar, which is what made it
|
||||
* outrun its width. They belong together and none of them is a place you go to
|
||||
* study, so they sit behind the one control that is about the person.
|
||||
*/
|
||||
function AccountMenu({ user, onLogout }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrap = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const away = e => { if (!wrap.current?.contains(e.target)) setOpen(false) }
|
||||
const onKey = e => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', away)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', away)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const initial = (user?.name || user?.email || '?').trim().charAt(0).toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="acct" ref={wrap}>
|
||||
<button type="button" className="acct-button" aria-haspopup="menu" aria-expanded={open}
|
||||
aria-label={`Account: ${user?.name || user?.email || 'signed in'}`}
|
||||
onClick={() => setOpen(v => !v)}>
|
||||
<span className="acct-avatar" aria-hidden="true">{initial}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="acct-menu" role="menu">
|
||||
<div className="acct-who">
|
||||
<strong>{user?.name}</strong>
|
||||
<span>{user?.email}</span>
|
||||
</div>
|
||||
<Link role="menuitem" to="/" onClick={() => setOpen(false)}>Dashboard</Link>
|
||||
<Link role="menuitem" to="/account" onClick={() => setOpen(false)}>Account</Link>
|
||||
<Link role="menuitem" to="/settings" onClick={() => setOpen(false)}>Settings</Link>
|
||||
<button type="button" role="menuitem" className="acct-signout"
|
||||
onClick={() => { setOpen(false); onLogout() }}>Sign out</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function Navbar({ onSignIn, onRegister }) {
|
||||
const { user, logout } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
|
@ -104,9 +154,11 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
}, [user])
|
||||
|
||||
|
||||
// Where you go to study. Home is the signed-out landing page, so it is not
|
||||
// one of these; Dashboard, Account and Settings are about you rather than
|
||||
// about the material, and live under the account menu — which is what keeps
|
||||
// this row short enough not to need scrolling in the first place.
|
||||
const navLinks = user ? [
|
||||
{ to: '/home', label: 'Home' },
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/ai', label: 'AI Mode' },
|
||||
// One entry. Sessions and analysis are the same subject — the list of what
|
||||
// you have sat and the reading of how it went — so they are one page.
|
||||
|
|
@ -119,7 +171,6 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
{ to: '/articles', label: 'Reading' },
|
||||
{ to: '/flashcards', label: 'Cards' },
|
||||
{ to: '/courses', label: 'Courses' },
|
||||
{ to: '/settings', label: '⚙ Settings' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
|
|
@ -134,7 +185,7 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
{user ? (
|
||||
<div className="navbar-account">
|
||||
<JobsBadge jobs={jobs} />
|
||||
<button className="nav-logout" onClick={logout}>Logout</button>
|
||||
<AccountMenu user={user} onLogout={logout} />
|
||||
<button
|
||||
className="nav-burger"
|
||||
onClick={() => setMenuOpen(v => !v)}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,27 @@ describe('two-bar header', () => {
|
|||
expect(screen.getAllByRole('link', { name: 'Manage Qs' })[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps what is about you out of the section bar', async () => {
|
||||
mount()
|
||||
const bar = screen.getByRole('navigation', { name: 'Sections' })
|
||||
// Home is the signed-out landing page; the rest are about the person, not
|
||||
// the material. Four fewer entries is what stops the bar outrunning its width.
|
||||
for (const gone of ['Home', 'Dashboard', '⚙ Settings']) {
|
||||
expect(within(bar).queryByRole('link', { name: gone })).not.toBeInTheDocument()
|
||||
}
|
||||
expect(within(bar).getByRole('link', { name: 'Sessions' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts them behind the account control instead', async () => {
|
||||
mount()
|
||||
await userEvent.click(screen.getByRole('button', { name: /Account:/ }))
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Dashboard' })).toHaveAttribute('href', '/')
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Settings' })).toHaveAttribute('href', '/settings')
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Account' })).toHaveAttribute('href', '/account')
|
||||
expect(within(menu).getByRole('menuitem', { name: 'Sign out' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the phone menu to one copy of the links', async () => {
|
||||
mount()
|
||||
expect(screen.queryByRole('link', { name: 'Study plans' })).toBeInTheDocument()
|
||||
|
|
@ -115,7 +136,8 @@ describe('two-bar header', () => {
|
|||
// Opening the burger adds the mobile copy; both exist in the DOM and CSS
|
||||
// shows one, so the count is what proves there is no third list.
|
||||
expect(screen.getAllByRole('link', { name: 'Study plans' })).toHaveLength(2)
|
||||
// One in the bar for a wide screen, one in the burger for a narrow one.
|
||||
expect(screen.getAllByRole('button', { name: 'Logout' })).toHaveLength(2)
|
||||
// Signing out is in the account menu on a wide screen and in the burger on
|
||||
// a narrow one — not two copies of the same button in the bar.
|
||||
expect(screen.getAllByRole('button', { name: 'Logout' })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -817,19 +817,8 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|||
/* Exam switcher — the study objective, above systems and disciplines.
|
||||
It sits on the light section bar now, so it is styled like every other
|
||||
control on a light surface rather than for a dark one. */
|
||||
.exam-switcher { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.exam-switcher select {
|
||||
background: var(--input-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 6px 10px; font-size: .8rem; font-weight: 600; cursor: pointer;
|
||||
/* Truncate with an ellipsis rather than clipping: "Pediatrics Boards (2948)"
|
||||
is wider than the old 220px cap, and a name cut mid-word reads as a
|
||||
rendering fault rather than as a name too long to show. */
|
||||
max-width: min(260px, 32vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.exam-switcher select:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.exam-switcher select option { color: var(--text); background: var(--card-bg); }
|
||||
@media (max-width: 720px) { .exam-switcher select { max-width: 150px; font-size: .74rem; } }
|
||||
/* The study-objective control lives in ExamSwitcher.css: it is a dialog now,
|
||||
not a <select> that had to be capped at 220px and clipped its own name. */
|
||||
|
||||
/* Visible only to a screen reader. This was used before it existed, which is
|
||||
why "Studying for" was printed next to the control it labels. */
|
||||
|
|
@ -849,3 +838,36 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Account menu ───────────────────────────────────────────────
|
||||
Dashboard, account, settings and sign-out were four more entries in the
|
||||
section bar, which is what made it outrun its width. They are about the
|
||||
person rather than the material, so they sit behind one control. */
|
||||
.acct { position: relative; }
|
||||
.acct-button {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; padding: 0;
|
||||
background: transparent !important; border: 1px solid rgba(255,255,255,0.25) !important;
|
||||
border-radius: 50%; cursor: pointer;
|
||||
}
|
||||
.acct-button:hover { border-color: rgba(255,255,255,0.55) !important; }
|
||||
.acct-avatar { font-size: 0.82rem; font-weight: 700; color: var(--navbar-fg); line-height: 1; }
|
||||
|
||||
.acct-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
|
||||
min-width: 210px; padding: 6px;
|
||||
background: var(--card-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.acct-who { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
||||
.acct-who strong { display: block; font-size: 0.88rem; }
|
||||
.acct-who span { display: block; font-size: 0.76rem; color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
.navbar .acct-menu a, .acct-menu button {
|
||||
display: block; width: 100%; min-height: 40px; padding: 9px 10px;
|
||||
font: inherit; font-size: 0.86rem; text-align: left;
|
||||
color: var(--text) !important; background: none !important;
|
||||
border: 0 !important; border-radius: 7px; cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.navbar .acct-menu a:hover, .acct-menu button:hover { background: var(--bg) !important; }
|
||||
.acct-menu .acct-signout { color: var(--wrong-fg) !important; border-top: 1px solid var(--border) !important; border-radius: 0 0 7px 7px; margin-top: 4px; }
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ describe('settings', () => {
|
|||
it('carries the exam objective, which was only reachable from the navbar', async () => {
|
||||
mount('/settings?s=study')
|
||||
expect(await screen.findByRole('heading', { name: 'Studying for' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByLabelText('Studying for')).toHaveValue('1'))
|
||||
// The control names the current objective rather than being a <select>
|
||||
// capped at 220px that clipped "Pediatrics Boards (2948)" mid-word.
|
||||
expect(await screen.findByRole('button', { name: /Boards/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks for a typed word before wiping practice data, and says what survives', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue