diff --git a/docs/TODO.md b/docs/TODO.md
index 7dcdffe..ec90265 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -277,8 +277,13 @@ Captured so nothing is lost while the article writing runs.
global notes tab that floated over the same screen, so it was never clear
which notepad you were writing in. The global note stays on the dashboard.
- [ ] **Per-question feedback** to the educator.
-- [x] ~~**Tutorial mode**~~ — not wanted. Coach marks over a product somebody
- is trying to use are a substitute for the product explaining itself.
+- [x] **Tutorial mode** — done 2026-09-12. Three coach marks on the overview,
+ once per browser: the prepared session, search versus AI Mode, and what
+ the ring is measuring. Steps are declared against a `data-tour`
+ attribute rather than a class, and one whose target is not on the page is
+ dropped from the count rather than shown pointing at nothing — an
+ educator and a learner do not see the same dashboard, and "Step 2 of 6"
+ must not promise a step that will never come.
- [x] **Per-question performance table** — done 2026-09-11 on the session
analysis: position, excerpt, difficulty, time, peer percentage; sortable
by position / slowest / hardest, ten a page.
@@ -289,9 +294,25 @@ Captured so nothing is lost while the article writing runs.
## Dashboard
-- [ ] **Overview page for signed-in users** — search hero with Search / AI Mode
- toggle, "Continue your study", and a study-analysis donut. The current
- dashboard becomes this; a separate signed-out landing page comes later.
+- [x] **Overview page for signed-in users** — done 2026-09-12. The dashboard is
+ now the overview: greeting, a search hero whose Search and AI Mode
+ controls open the one Ctrl+K panel on the tab they name (a second search
+ box would be a worse copy of the panel's history, typeahead and hand-off
+ into a conversation), the prepared session, "Continue your study", and a
+ donut. One column on a phone, two from 1000px.
+ **The donut is a glance, not a page** — the ring, its key and one line of
+ basis, on the latest-attempt reading, with "See full analysis" beside the
+ heading. The trend, the peer comparison and the focus-area table stay on
+ `/sessions`, where they are worth the room.
+ **A new account is the good case, not the degraded one** — one request to
+ `/study-tools/answer-split` decides. With answers behind it: the ring, and
+ MyNote under it. With none: no ring, no notebook, no dashes — the
+ cold-start prepared session (which already plans an even spread across the
+ blueprint and says so) plus three doors to reading, study plans and AI
+ Mode. A failed request shows neither rather than calling an established
+ account new. The old dashboard fetched `/attempts/stats/dashboard` and
+ `/attempts/history` on every visit and rendered neither; both are gone.
+ Still on the signed-out landing page as a separate job.
## Analysis, as specified 2026-09-11 (evening)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 30cda11..7b4d5aa 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,4 @@
-import { Suspense, useEffect, useState } from 'react'
+import { Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet, Link, useLocation, useParams } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { SessionDrawerProvider } from './context/SessionDrawer'
@@ -7,9 +7,10 @@ import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar'
import SiteFooter from './components/SiteFooter'
import ChooseObjective from './components/ChooseObjective'
-import SearchOverlay from './components/SearchOverlay'
+import { SearchPanelProvider, useSearchPanel } from './context/SearchPanel'
import ErrorBoundary from './components/ErrorBoundary'
import lazyPage from './utils/lazyPage'
+import FirstRunTour from './components/FirstRunTour'
const LoginPage = lazyPage(() => import('./pages/LoginPage'))
const RegisterPage = lazyPage(() => import('./pages/RegisterPage'))
@@ -61,26 +62,23 @@ function LoadingFallback() {
// Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() {
+ // The panel, its keyboard shortcut and the overlay itself live in the
+ // provider, because the header is no longer the only thing that opens it —
+ // the overview page's hero does too, and it is inside the Outlet.
+ return (
+
+
+
+ )
+}
+
+function AppFrame() {
// Keyed by path so navigating away from a broken page clears the error.
const location = useLocation()
- const [searching, setSearching] = useState(false)
+ const { open: openSearch } = useSearchPanel()
const inSession = useInSession()
const fullBleed = useFullBleed()
- 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()
- setSearching(true)
- }
- }
- document.addEventListener('keydown', onKey)
- return () => document.removeEventListener('keydown', onKey)
- }, [])
-
return (
/* A column the height of the window, so the footer is at the bottom of the
screen rather than at the bottom of the content — a page still loading
@@ -91,11 +89,10 @@ function AppLayout() {
means the whole bank, which is a reasonable default and a poor thing
to arrive at without being asked. */}
- {/* 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. */}
- setSearching(false)} />
- setSearching(true)} />
+ {/* After the objective, never beside it: two things asking for attention
+ at once is one of them being dismissed unread. */}
+
+ openSearch('search')} />
{/* A session takes the window. The page's own gutters and 1200px cap
are for reading; a player is a fixed-height box whose columns scroll
inside it, and every pixel the container reserves is a pixel the
diff --git a/frontend/src/components/CoachMarks.css b/frontend/src/components/CoachMarks.css
new file mode 100644
index 0000000..665c21a
--- /dev/null
+++ b/frontend/src/components/CoachMarks.css
@@ -0,0 +1,40 @@
+/* ── The first-run walk ───────────────────────────────────────────────
+ A hole in a dimmed page, a ring around what is being named, and a card
+ beside it. The dimming is four rectangles rather than one enormous
+ box-shadow: a shadow that size repaints the whole screen on every scroll
+ event, and this thing follows scrolling. */
+.tour { position: absolute; inset: 0; z-index: 1400; }
+.tour-shade { position: absolute; left: 0; right: 0; background: rgba(15, 23, 42, 0.55); }
+.tour-ring {
+ position: absolute; pointer-events: none;
+ border: 2px solid var(--primary); border-radius: 10px;
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, .65);
+}
+
+.tour-card {
+ position: absolute; width: min(320px, calc(100vw - 24px));
+ padding: 16px 18px; background: var(--card-bg); color: var(--text);
+ border-radius: 12px; box-shadow: 0 18px 44px rgba(15, 23, 42, .3);
+}
+.tour-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
+.tour-step {
+ margin: 0 0 6px; font-size: 0.7rem; font-weight: 700;
+ letter-spacing: 0.07em; text-transform: uppercase; color: var(--text-subtle);
+}
+.tour-card h2 { margin: 0 0 8px; font-size: 1rem; }
+.tour-body { margin: 0 0 14px; font-size: 0.88rem; line-height: 1.55; color: var(--text-muted); }
+
+.tour-actions { display: flex; align-items: center; gap: 8px; }
+/* Skip sits apart from the two that move you along, so leaving is never the
+ button your hand is already on. */
+.tour-skip {
+ margin-right: auto; padding: 6px 2px; cursor: pointer;
+ font: inherit; font-size: 0.83rem; color: var(--text-subtle);
+ background: none; border: 0;
+}
+.tour-skip:hover { color: var(--text); text-decoration: underline; }
+
+@media (prefers-reduced-motion: no-preference) {
+ .tour-card { animation: tour-in 160ms ease-out; }
+}
+@keyframes tour-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; } }
diff --git a/frontend/src/components/CoachMarks.jsx b/frontend/src/components/CoachMarks.jsx
new file mode 100644
index 0000000..b5a763d
--- /dev/null
+++ b/frontend/src/components/CoachMarks.jsx
@@ -0,0 +1,145 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import './CoachMarks.css'
+
+/**
+ * The short walk somebody gets the first time, and never again.
+ *
+ * A tour is a confession that the product does not explain itself, so this one
+ * is deliberately small: it names the three or four things a learner would
+ * otherwise have to go looking for, and gets out of the way. Anything that
+ * needs six steps to explain wants rebuilding, not narrating.
+ *
+ * Steps are declared against a `data-tour` attribute rather than a CSS class
+ * or a ref, because a tour that reaches into another component's internals
+ * breaks the first time somebody renames a class for a visual reason. A step
+ * whose target is not on the page is skipped rather than shown pointing at
+ * nothing — pages differ by role, and an educator and a learner do not see the
+ * same dashboard.
+ *
+ * Seen-ness is per browser, in localStorage: a tour is about the interface,
+ * not about the account, and somebody arriving on a new machine has not lost
+ * anything by not seeing it again.
+ */
+const SEEN_KEY = 'pedshub.tourSeen'
+
+const seen = () => {
+ try { return localStorage.getItem(SEEN_KEY) === 'yes' } catch { return true }
+}
+const remember = () => {
+ try { localStorage.setItem(SEEN_KEY, 'yes') } catch { /* private mode: it will offer once more */ }
+}
+
+/** Where the target is now, in page coordinates, or null if it is not there. */
+function locate(name) {
+ const node = document.querySelector(`[data-tour="${name}"]`)
+ if (!node) return null
+ const box = node.getBoundingClientRect()
+ if (!box.width && !box.height) return null // rendered but hidden
+ return {
+ top: box.top + window.scrollY,
+ left: box.left + window.scrollX,
+ width: box.width,
+ height: box.height,
+ }
+}
+
+export default function CoachMarks({ steps = [], onDone }) {
+ const [index, setIndex] = useState(0)
+ const [live, setLive] = useState([])
+ const [spot, setSpot] = useState(null)
+ const card = useRef(null)
+
+ // Which steps have something to point at. Computed once, on the page as it
+ // stands: a step that appears later would move the numbering under somebody
+ // mid-tour, and "Step 2 of 6" becoming "Step 2 of 7" is worse than missing a
+ // step nobody was promised.
+ useEffect(() => {
+ const present = steps.filter(step => locate(step.target))
+ setLive(present)
+ if (!present.length) onDone?.()
+ }, [steps, onDone])
+
+ const step = live[index]
+
+ const place = useCallback(() => {
+ if (!step) return
+ const at = locate(step.target)
+ if (!at) { setIndex(i => i + 1); return } // vanished mid-tour
+ setSpot(at)
+ }, [step])
+
+ // Layout, not effect: the hole and the card have to be in place in the same
+ // paint as the overlay, or the first frame shows a black screen.
+ useLayoutEffect(() => {
+ place()
+ window.addEventListener('resize', place)
+ window.addEventListener('scroll', place, true)
+ return () => {
+ window.removeEventListener('resize', place)
+ window.removeEventListener('scroll', place, true)
+ }
+ }, [place])
+
+ useEffect(() => { card.current?.focus() }, [index])
+
+ const finish = useCallback(() => { remember(); onDone?.() }, [onDone])
+
+ useEffect(() => {
+ const onKey = (event) => {
+ if (event.key === 'Escape') finish()
+ if (event.key === 'ArrowRight') setIndex(i => i + 1)
+ if (event.key === 'ArrowLeft') setIndex(i => Math.max(0, i - 1))
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [finish])
+
+ useEffect(() => { if (live.length && index >= live.length) finish() }, [index, live.length, finish])
+
+ if (!step || !spot) return null
+
+ // Beneath the target where there is room, above it where there is not.
+ const below = spot.top + spot.height + 12 - window.scrollY + 190 < window.innerHeight
+ const cardStyle = {
+ top: below ? spot.top + spot.height + 12 : undefined,
+ bottom: below ? undefined : `calc(100% - ${spot.top - 12}px)`,
+ left: Math.max(12, Math.min(spot.left, window.innerWidth - 340)),
+ }
+
+ return (
+
+ {/* Four panels rather than one box-shadow: a shadow that large is a
+ repaint of the whole screen on every scroll, and this follows one. */}
+
+
+
+
+
+
+
+
Step {index + 1} of {live.length}
+
{step.title}
+
{step.body}
+
+
+ {index > 0 && (
+
+ )}
+
+
+
+
+ )
+}
+
+/** Whether this browser has been shown the tour. Exported for the caller. */
+export const tourSeen = seen
diff --git a/frontend/src/components/CoachMarks.test.jsx b/frontend/src/components/CoachMarks.test.jsx
new file mode 100644
index 0000000..80586fa
--- /dev/null
+++ b/frontend/src/components/CoachMarks.test.jsx
@@ -0,0 +1,79 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import CoachMarks, { tourSeen } from './CoachMarks'
+
+const STEPS = [
+ { target: 'here', title: 'Start here', body: 'A session, built for you.' },
+ { target: 'there', title: 'And there', body: 'What you have not answered yet.' },
+ { target: 'nowhere', title: 'Missing', body: 'Nothing points at this.' },
+]
+
+// jsdom gives every element a zero-sized rect, so a real target has to say it
+// has a size or the tour would treat it as hidden.
+const target = (name, size = { width: 200, height: 40 }) => {
+ const node = document.createElement('div')
+ node.setAttribute('data-tour', name)
+ node.getBoundingClientRect = () => ({ top: 100, left: 50, ...size })
+ document.body.appendChild(node)
+ return node
+}
+
+describe('the first-run walk', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ document.body.innerHTML = ''
+ })
+
+ it('counts only the steps that have something to point at', async () => {
+ target('here'); target('there')
+ render( {}} />)
+ // Three steps declared, two on the page. Promising six and showing five is
+ // worse than never mentioning the sixth.
+ expect(await screen.findByText('Step 1 of 2')).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Start here' })).toBeInTheDocument()
+ })
+
+ it('walks forward and back, and finishes on the last step', async () => {
+ target('here'); target('there')
+ const onDone = vi.fn()
+ render()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }))
+ expect(screen.getByText('Step 2 of 2')).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: 'Back' }))
+ expect(screen.getByText('Step 1 of 2')).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }))
+ await userEvent.click(screen.getByRole('button', { name: 'Done' }))
+ expect(onDone).toHaveBeenCalled()
+ expect(tourSeen()).toBe(true)
+ })
+
+ it('does not come back once it has been skipped', async () => {
+ target('here')
+ const onDone = vi.fn()
+ render()
+ await userEvent.click(screen.getByRole('button', { name: 'Skip' }))
+ expect(onDone).toHaveBeenCalled()
+ // Skipping is a decision, not a postponement.
+ expect(tourSeen()).toBe(true)
+ })
+
+ it('shows nothing at all when the page has none of its targets', () => {
+ const onDone = vi.fn()
+ render()
+ expect(screen.queryByRole('dialog')).toBeNull()
+ // An educator and a learner do not see the same page; a tour with nothing
+ // to say should not announce itself.
+ expect(onDone).toHaveBeenCalled()
+ })
+
+ it('leaves on Escape', async () => {
+ target('here')
+ const onDone = vi.fn()
+ render()
+ await userEvent.keyboard('{Escape}')
+ expect(onDone).toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/components/FirstRunTour.jsx b/frontend/src/components/FirstRunTour.jsx
new file mode 100644
index 0000000..f0e883a
--- /dev/null
+++ b/frontend/src/components/FirstRunTour.jsx
@@ -0,0 +1,60 @@
+import { lazy, Suspense, useEffect, useState } from 'react'
+import { useLocation } from 'react-router-dom'
+import { tourSeen } from './CoachMarks'
+
+// Only ever loaded for the one visit that shows it.
+const CoachMarks = lazy(() => import('./CoachMarks'))
+
+/**
+ * Three things a learner would otherwise go looking for.
+ *
+ * Kept short on purpose. A tour is a confession that the product does not
+ * explain itself, so this one names what is not obvious and stops — anything
+ * needing six steps wants rebuilding rather than narrating.
+ *
+ * Only on the overview, and only once per browser. Following somebody around
+ * the site would make it a thing to escape rather than a thing to read, and
+ * the overview is where all three of these actually are.
+ */
+const STEPS = [
+ {
+ target: 'prepared',
+ title: 'A session chosen for you',
+ body: 'It picks what you have not seen, then your weakest topics, weighted by '
+ + 'how much of the real exam each one carries. You see the plan before it starts.',
+ },
+ {
+ target: 'search',
+ title: 'Search, or ask',
+ body: 'Search finds questions and reading. AI Mode answers from your library and '
+ + 'shows you what it used. Ctrl+K opens either from anywhere.',
+ },
+ {
+ target: 'glance',
+ title: 'Where you stand',
+ body: 'Your latest answer to each question, not an average of every attempt. '
+ + 'The full analysis is one click away.',
+ },
+]
+
+export default function FirstRunTour() {
+ const location = useLocation()
+ // Read once, on mount, rather than on every render: marking it seen must not
+ // yank the tour off the screen mid-step.
+ const [show, setShow] = useState(() => location.pathname === '/' && !tourSeen())
+
+ // The dashboard mounts its blocks after a request settles, and a tour that
+ // measures too early finds nothing and concludes there is nothing to show.
+ const [ready, setReady] = useState(false)
+ useEffect(() => {
+ const timer = setTimeout(() => setReady(true), 600)
+ return () => clearTimeout(timer)
+ }, [])
+
+ if (!show || !ready) return null
+ return (
+
+ setShow(false)} />
+
+ )
+}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index a69166b..a4e68ee 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -1,11 +1,13 @@
-import { useState, useEffect } from 'react'
-import ContinueStudy from '../components/ContinueStudy'
-import PreparedSession from '../components/PreparedSession'
+import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
-import LineChart from '../components/LineChart'
+import ContinueStudy from '../components/ContinueStudy'
+import PreparedSession from '../components/PreparedSession'
+import Donut from '../components/Donut'
import MyNote from '../components/MyNote'
import { useAuth } from '../context/AuthContext'
+import { useSearchPanel } from '../context/SearchPanel'
+import './DashboardPage.css'
/**
* The line at the top of the dashboard.
@@ -35,64 +37,172 @@ export function greeting(name, now = new Date()) {
return `${time}${first ? `, ${first}` : ''}`
}
+/**
+ * The way in for somebody who arrived with a question rather than with time to
+ * study.
+ *
+ * Both controls open the one search panel, on the tab they name. It is not a
+ * search box of its own — the panel has the history, the typeahead and the
+ * hand-off into a conversation, and a second field here would be a worse copy
+ * of all three. It earns the space anyway, because the header's box is hidden
+ * below 900px: on a phone this is the only search on the site.
+ *
+ * The two controls are not `role="tab"`: nothing is selected until the panel
+ * is open, and a pair of tabs where neither is on is a lie told to a screen
+ * reader.
+ */
+function SearchHero() {
+ const { open } = useSearchPanel()
+ return (
+
+
Search
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Progress, at the size a glance deserves.
+ *
+ * One ring and one sentence. Everything else about these answers — the trend,
+ * the peer comparison, the topic-by-topic table — is a click away on the
+ * analysis page and belongs there; repeating a slice of it here would give a
+ * learner two places to read the same figures and a reason to doubt both when
+ * they disagree.
+ *
+ * Latest-attempt basis: the question a glance asks is what is known now, not
+ * how much work has been done. The full page offers both and says which is
+ * which.
+ */
+function Glance({ split }) {
+ const now = split.latest
+ return (
+
+
+
Where you stand
+ See full analysis
+
+
+
+
+
{now.correct} correct
+ {now.correct_with_hints > 0 && (
+
{now.correct_with_hints} correct after a tip
+ )}
+
{now.incorrect} incorrect
+ {now.unanswered > 0 &&
{now.unanswered} unanswered
}
+
+
+
+ Your most recent answer to each of {split.unique_questions} question
+ {split.unique_questions === 1 ? '' : 's'}, across {split.attempts} session
+ {split.attempts === 1 ? '' : 's'}.
+
+
+ )
+}
+
+/**
+ * What an account with no answers behind it gets instead of a shelf of dashes.
+ *
+ * The prepared session above this one already works on the first visit — it
+ * falls back to an even spread across the blueprint and says so — so the
+ * opening act is a real session, not an onboarding tour. This is the rest of
+ * the building, for somebody who would rather read first or does not want
+ * questions today.
+ */
+const FIRST_STEPS = [
+ { to: '/articles', title: 'Read first', body: 'The reference library, filed by topic and cross-linked.' },
+ { to: '/study-plans', title: 'Follow a plan', body: 'A structured route through the material, block by block.' },
+ { to: '/ai', title: 'Ask a question', body: 'Answers drawn from the library, with every source cited.' },
+]
+
+function FirstSteps() {
+ return (
+
+
Other ways to start
+
+ {FIRST_STEPS.map(step => (
+
+
+ {step.title}
+ {step.body}
+
+
+ ))}
+
+
+ )
+}
+
+/**
+ * The overview: what to do now, what was left half-done, and how it is going.
+ *
+ * Each block below has to justify the room it takes, because a page that shows
+ * everything is a page nobody reads any of. Deliberately absent: the trend
+ * chart, the completion tiles and the focus-area table, all of which are one
+ * click away under Sessions and are worth a page rather than a corner.
+ */
export default function DashboardPage() {
const { user } = useAuth()
- const [stats, setStats] = useState(null)
- const [history, setHistory] = useState([])
- const [selectedQuizId, setSelectedQuizId] = useState(null)
- const [loading, setLoading] = useState(true)
- const [deletingAttempt, setDeletingAttempt] = useState(null)
-
- const [confirmAttempt, setConfirmAttempt] = useState(null)
-
- const deleteAttempt = async (attemptId) => {
- if (confirmAttempt !== attemptId) { setConfirmAttempt(attemptId); return }
- setDeletingAttempt(attemptId)
- setConfirmAttempt(null)
- try {
- await api.delete(`/attempts/${attemptId}`)
- setHistory(prev => prev.map(q => ({
- ...q,
- attempts: q.attempts.filter(a => a.attempt_id !== attemptId),
- })).filter(q => q.attempts.length > 0))
- } catch { }
- finally { setDeletingAttempt(null) }
- }
+ //: One request answers two questions: what the ring should show, and whether
+ //: this account has anything to put in one. Left null on failure, which
+ //: shows neither rather than claiming a fresh account.
+ const [split, setSplit] = useState(null)
+ const [loaded, setLoaded] = useState(false)
useEffect(() => {
- const promises = [
- api.get('/attempts/stats/dashboard'),
- api.get('/attempts/history'),
- ]
- Promise.all(promises).then(([statsRes, histRes]) => {
- setStats(statsRes.data)
- setHistory(histRes.data)
- if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
- }).catch(console.error)
- .finally(() => setLoading(false))
+ let live = true
+ api.get('/study-tools/answer-split')
+ .then(res => { if (live) setSplit(res.data) })
+ .catch(() => { if (live) setSplit(null) })
+ .finally(() => { if (live) setLoaded(true) })
+ return () => { live = false }
}, [])
- if (loading) return
{/* The greeting names the page and nothing else; the first section
below is its own heading. Sized as a page title rather than a section
one, because below the fold it read as a heading for the wrong
section. */}
-
-
{greetingText}
-
- {/* What to do now, before what was left half-done: the session that is
- waiting is a recovery path, and it is directly underneath. */}
-
-
-
+
{greeting(user?.name)}
+ {/* Looking something up is not studying, and somebody who came to do it
+ should not have to scroll past a study plan to get at the box. */}
+
+
+ {/* One column on a phone, two where there is room — the stylesheet
+ decides, and the source order is the narrow one. */}
+
+ {/* What to do now, before what was left half-done: the session that is
+ waiting is a recovery path, and it is directly underneath. */}
+
+
+
+ {/* Mutually exclusive by construction: a ring drawn from nothing is
+ the empty state this page exists to avoid, and three doors offered
+ to somebody already mid-course is clutter. */}
+ {started &&
}
+ {fresh && }
+
+ {/* Last, and only once there are answers behind it. A notebook with
+ nothing in it is furniture; it turns up when there is something
+ worth writing down. */}
+ {started && }
+