diff --git a/frontend/src/components/SearchOverlay.jsx b/frontend/src/components/SearchOverlay.jsx
index 1ec5207..dea4cbf 100644
--- a/frontend/src/components/SearchOverlay.jsx
+++ b/frontend/src/components/SearchOverlay.jsx
@@ -36,8 +36,8 @@ function remember(query) {
* conversation and lets the answer arrive there, which is where the follow-up
* will be asked from anyway.
*/
-export default function SearchOverlay({ open, onClose }) {
- const [tab, setTab] = useState('search')
+export default function SearchOverlay({ open, onClose, tab: opensOn = 'search' }) {
+ const [tab, setTab] = useState(opensOn)
const [query, setQuery] = useState('')
const [items, setItems] = useState([])
const [active, setActive] = useState(-1)
@@ -47,13 +47,17 @@ export default function SearchOverlay({ open, onClose }) {
useEffect(() => {
if (!open) return undefined
+ // Whichever tab the opener asked for, every time it opens. Remembering the
+ // tab left behind would mean a control labelled AI Mode lands on the corpus
+ // search because of something done on another page an hour ago.
+ setTab(opensOn)
setHistory(readHistory())
// Focused on the next frame: the element does not exist until this render
// has been painted, and focusing a node that is not in the document does
// nothing at all.
const timer = requestAnimationFrame(() => field.current?.focus())
return () => cancelAnimationFrame(timer)
- }, [open])
+ }, [open, opensOn])
useEffect(() => {
if (!open || tab !== 'search' || query.trim().length < 2) { setItems([]); return undefined }
diff --git a/frontend/src/context/SearchPanel.jsx b/frontend/src/context/SearchPanel.jsx
new file mode 100644
index 0000000..a46a4e4
--- /dev/null
+++ b/frontend/src/context/SearchPanel.jsx
@@ -0,0 +1,54 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import SearchOverlay from '../components/SearchOverlay'
+
+/**
+ * Who may open the search panel: the header, the keyboard, and the hero on the
+ * overview page.
+ *
+ * There is one panel with two tabs, so none of those three is a search of its
+ * own — each is another door to the same one. Two boxes that do nearly the
+ * same thing is how people learn to trust neither, and the header already
+ * settled that argument for itself; the hero would otherwise have reopened it.
+ *
+ * Which tab it lands on is the caller's to say. A control labelled AI Mode is a
+ * promise about where you end up, and landing on the corpus search instead
+ * breaks it.
+ */
+const Context = createContext({ open: () => {} })
+
+export function SearchPanelProvider({ children }) {
+ //: The tab is the open state — null is shut. A separate boolean beside it is
+ //: a second thing that has to agree about whether the panel is on screen.
+ const [tab, setTab] = useState(null)
+ const open = useCallback((which = 'search') => setTab(which), [])
+ const close = useCallback(() => setTab(null), [])
+
+ useEffect(() => {
+ const onKey = (event) => {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
+ // Taken from the browser deliberately: Ctrl+K is what every tool this
+ // sits beside uses, and a learner who has learned it once should not
+ // have to learn ours.
+ event.preventDefault()
+ open('search')
+ }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [open])
+
+ const value = useMemo(() => ({ open }), [open])
+
+ return (
+
+ {children}
+ {/* Over whatever is on screen: a question arrives while you are reading
+ something, and having to leave that page to ask is how it gets
+ dropped. */}
+
+
+ )
+}
+
+/** `open('search' | 'ai')`. Does nothing outside the authenticated shell. */
+export const useSearchPanel = () => useContext(Context)
diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css
new file mode 100644
index 0000000..391ba9e
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.css
@@ -0,0 +1,83 @@
+/* The signed-in overview: search, the session waiting for you, and a glance
+ at how it is going. The blocks themselves are shared components with their
+ own stylesheets; this file only greets, draws the hero, and decides where
+ things sit. */
+
+.ov-greeting { margin: 0 0 16px; font-size: 1.5rem; font-weight: 700; color: var(--text); }
+
+/* --- the hero ------------------------------------------------------------ */
+
+.ov-hero { margin-bottom: 20px; }
+
+.ov-hero-modes { display: inline-flex; gap: 4px; margin-bottom: 10px; padding: 3px; border-radius: 10px; background: var(--bg); }
+.ov-hero-modes button {
+ border: 0; background: none; color: var(--text-muted); cursor: pointer;
+ font: inherit; font-size: 0.86rem; font-weight: 600; padding: 7px 16px; border-radius: 8px;
+}
+.ov-hero-modes button:hover { color: var(--primary); background: var(--card-bg); }
+
+/* A door, not a field — it looks like one because that is what people reach
+ for, and clicking anywhere on it opens the panel with the cursor already in
+ the real box. */
+.ov-hero-field {
+ display: flex; align-items: center; gap: 12px; width: 100%;
+ min-height: 52px; padding: 12px 16px; cursor: pointer; text-align: left;
+ font: inherit; font-size: 1rem; color: var(--text-subtle, var(--text-muted));
+ background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
+}
+.ov-hero-field:hover { border-color: var(--primary); }
+.ov-hero-field:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
+.ov-hero-icon { font-size: 1.2rem; line-height: 1; color: var(--text-muted); }
+.ov-hero-prompt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ov-hero-field kbd {
+ flex-shrink: 0; padding: 3px 7px; border-radius: 6px; font: inherit; font-size: 0.7rem;
+ color: var(--text-muted); background: var(--bg); border: 1px solid var(--border);
+}
+
+/* --- progress at a glance ------------------------------------------------ */
+
+.ov-glance, .ov-first {
+ background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
+ padding: 16px; margin-bottom: 20px;
+}
+.ov-glance-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
+.ov-glance-head h2 { margin: 0; font-size: 1.15rem; font-weight: 650; }
+.ov-glance-head a { font-size: 0.82rem; color: var(--primary); text-decoration: none; white-space: nowrap; }
+.ov-glance-head a:hover { text-decoration: underline; }
+.ov-basis { margin: 12px 0 0; font-size: 0.78rem; color: var(--text-muted); }
+
+/* --- a first visit ------------------------------------------------------- */
+
+.ov-first h2 { margin: 0 0 12px; font-size: 1.15rem; font-weight: 650; }
+.ov-first-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
+.ov-first-grid a {
+ display: flex; flex-direction: column; gap: 4px; height: 100%;
+ padding: 13px 14px; border: 1px solid var(--border); border-radius: 10px;
+ color: var(--text); text-decoration: none; background: var(--bg);
+}
+.ov-first-grid a:hover { border-color: var(--primary); }
+.ov-first-grid strong { font-size: 0.92rem; font-weight: 650; }
+.ov-first-grid span { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; }
+
+/* --- where the blocks sit ------------------------------------------------ */
+
+/* One column until there is room for two, which is the order they are written
+ in and the only order that makes sense on a phone: what to do now, what was
+ left open, then how it is going.
+
+ Two columns puts the ring beside the session plan rather than below two tall
+ panels. Dense packing rather than explicit rows because half these blocks
+ are conditional — a fresh account has no ring and no notebook — and fixed
+ row numbers would leave holes where those would have been. */
+@media (min-width: 1000px) {
+ .ov-body {
+ display: grid; grid-auto-flow: row dense; align-items: start;
+ grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 20px;
+ }
+ .ov-body > .ps-panel, .ov-body > .cs-panel { grid-column: 1; }
+ .ov-body > .ov-glance, .ov-body > .ov-first, .ov-body > .mynote-card { grid-column: 2; }
+ /* Roughly the width of the column beneath it. A search box the full 1200px
+ with a short prompt at the left end reads as a page that ran out of
+ content, not as a generous target. */
+ .ov-hero { max-width: 720px; }
+}
diff --git a/frontend/src/pages/DashboardPage.test.jsx b/frontend/src/pages/DashboardPage.test.jsx
new file mode 100644
index 0000000..b85a263
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.test.jsx
@@ -0,0 +1,147 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router-dom'
+import { beforeEach, expect, it, vi } from 'vitest'
+import DashboardPage from './DashboardPage'
+import { SearchPanelProvider } from '../context/SearchPanel'
+import api from '../api/client'
+
+vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
+vi.mock('../context/AuthContext', () => ({
+ useAuth: () => ({ user: { id: 1, name: 'Ada Lovelace', role: 'user' } }),
+}))
+
+const split = (latest, extra = {}) => ({
+ all: latest,
+ latest,
+ attempts: 3,
+ unique_questions: latest.total,
+ ...extra,
+})
+
+const NOTHING = { correct: 0, correct_with_hints: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null }
+const SOME = { correct: 12, correct_with_hints: 2, incorrect: 6, unanswered: 0, answered: 20, total: 20, percent_correct: 70 }
+
+const COLD_PLAN = {
+ count: 20, basis: 'cold_start', new_count: 20, review_count: 0, available: 2900,
+ summary: "You haven't finished a session yet, so this is an even spread across the exam blueprint.",
+ length_reason: '20 to start with.',
+ topics: [{ category_id: 1, name: 'Cardiology', count: 20, new_count: 20, review_count: 0, accuracy: null, weight: 60, reason: 'Worth 60% of the exam' }],
+}
+
+const IN_FLIGHT = [
+ { quiz_id: 5, title: 'Prepared session', mode: 'learning', state: 'in_progress', answered: 4, total: 20, last_attempt_id: 9 },
+]
+
+/** Everything the page and its blocks ask for, with the account's state swapped in. */
+const server = ({ answers = NOTHING, sessions = [], plan = COLD_PLAN } = {}) => {
+ api.get.mockImplementation((url) => {
+ if (url === '/study-tools/answer-split') return Promise.resolve({ data: split(answers) })
+ if (url === '/questions/builder/prepared') return Promise.resolve({ data: plan })
+ if (url === '/quizzes/sessions') return Promise.resolve({ data: sessions })
+ if (url === '/articles/recent') return Promise.resolve({ data: [] })
+ if (url === '/exams/') return Promise.resolve({ data: { exams: [], active_exam_id: null } })
+ if (url === '/mynote') return Promise.resolve({ data: { content: 'Kawasaki criteria', updated_at: null } })
+ return Promise.resolve({ data: {} })
+ })
+}
+
+const mount = () => render(
+
+
+
+)
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ server()
+})
+
+it('greets the learner by their first name', async () => {
+ mount()
+ expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent(/, Ada$/)
+})
+
+/* The case anybody evaluating this product sees first, and the one a dashboard
+ built out of statistics gets wrong: an account with nothing behind it. */
+it('gives a new account something to do rather than a shelf of dashes', async () => {
+ mount()
+
+ // A real session, planned and explained, is the opening act.
+ expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Start session' })).toBeInTheDocument()
+
+ // And the rest of the building, for somebody who would rather read first.
+ expect(screen.getByRole('heading', { name: 'Other ways to start' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Read first/ })).toHaveAttribute('href', '/articles')
+ expect(screen.getByRole('link', { name: /Follow a plan/ })).toHaveAttribute('href', '/study-plans')
+ expect(screen.getByRole('link', { name: /Ask a question/ })).toHaveAttribute('href', '/ai')
+
+ // Nothing that would be empty: no ring drawn from no answers, no half-done
+ // session, no notebook with nothing in it.
+ expect(screen.queryByRole('heading', { name: 'Where you stand' })).not.toBeInTheDocument()
+ expect(document.querySelector('.an-donut')).toBeNull()
+ expect(screen.queryByText('Continue your study')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Open MyNote' })).not.toBeInTheDocument()
+ expect(screen.queryByText('—')).not.toBeInTheDocument()
+})
+
+it('shows the ring once there are answers, and keeps the rest on the analysis page', async () => {
+ server({ answers: SOME, sessions: IN_FLIGHT })
+ mount()
+
+ expect(await screen.findByRole('heading', { name: 'Where you stand' })).toBeInTheDocument()
+ expect(screen.getByText('70%')).toBeInTheDocument()
+ expect(screen.getByText('12 correct')).toBeInTheDocument()
+ expect(screen.getByText('2 correct after a tip')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'See full analysis' }))
+ .toHaveAttribute('href', '/sessions?view=performance')
+
+ // A glance, not a second analysis page: the trend, the peer comparison and
+ // the focus-area table stay where they are.
+ expect(screen.queryByText('Performance over time')).not.toBeInTheDocument()
+ expect(screen.queryByText('Against everyone else')).not.toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: 'Other ways to start' })).not.toBeInTheDocument()
+})
+
+it('offers the session left open, without going into it', async () => {
+ server({ answers: SOME, sessions: IN_FLIGHT })
+ mount()
+ const resume = await screen.findByRole('link', { name: 'Resume' })
+ expect(resume).toHaveAttribute('href', '/study/5')
+})
+
+it('opens the one search panel rather than searching for itself', async () => {
+ mount()
+ await screen.findByRole('heading', { name: 'Ready for you' })
+ // Nothing to type into until the panel is open — the hero is a door.
+ expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Search' }))
+ expect(await screen.findByRole('dialog', { name: 'Search' })).toBeInTheDocument()
+ expect(screen.getByRole('searchbox', { name: 'Search PedsHub' })).toBeInTheDocument()
+})
+
+it('lands on AI Mode when that is what was asked for', async () => {
+ mount()
+ await screen.findByRole('heading', { name: 'Ready for you' })
+ await userEvent.click(screen.getByRole('button', { name: 'AI Mode' }))
+ expect(await screen.findByRole('dialog', { name: 'AI Mode' })).toBeInTheDocument()
+ expect(screen.getByRole('searchbox', { name: 'Ask AI Mode' })).toBeInTheDocument()
+})
+
+it('says nothing about progress it could not load', async () => {
+ api.get.mockImplementation((url) => (
+ url === '/study-tools/answer-split'
+ ? Promise.reject(new Error('down'))
+ : Promise.resolve({ data: url === '/questions/builder/prepared' ? COLD_PLAN : [] })
+ ))
+ mount()
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith('/study-tools/answer-split'))
+ // Neither the ring nor the first-visit block: one would be drawn from
+ // nothing and the other would call an established account new.
+ expect(screen.queryByRole('heading', { name: 'Where you stand' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: 'Other ways to start' })).not.toBeInTheDocument()
+ expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument()
+})