diff --git a/backend/scripts/backfill_section_index.py b/backend/scripts/backfill_section_index.py deleted file mode 100644 index 3439bf6..0000000 --- a/backend/scripts/backfill_section_index.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Project existing article sections into the section index. - -`_rebuild_section_index` runs when an article is saved, so articles written -before that existed have no rows — the index was empty and section-level -retrieval had nothing to search. - - docker compose exec backend python -m scripts.backfill_section_index - docker compose exec backend python -m scripts.backfill_section_index --apply -""" -import sys - -from app.database import SessionLocal -from app.models.article import Article -from app.routers.articles import _rebuild_section_index - - -def main(): - apply_changes = "--apply" in sys.argv - db = SessionLocal() - try: - articles = db.query(Article).all() - sections = sum(len(a.sections or []) for a in articles) - print(f" articles : {len(articles)}") - print(f" sections : {sections}") - if apply_changes: - for article in articles: - _rebuild_section_index(db, article) - db.commit() - print(" indexed and embedded.") - else: - print("\n Re-run with --apply to build the index.") - finally: - db.close() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/frontend/src/components/ModelsAdmin.jsx b/frontend/src/components/ModelsAdmin.jsx index badc3c8..c1bfd6f 100644 --- a/frontend/src/components/ModelsAdmin.jsx +++ b/frontend/src/components/ModelsAdmin.jsx @@ -11,6 +11,7 @@ const JOBS = [ { key: 'article', label: 'Articles', hint: 'Drafting and refining reading.' }, { key: 'flashcard', label: 'Cards', hint: 'Generating flashcard decks.' }, { key: 'keyword', label: 'Classification', hint: 'Suggesting subjects and tags.' }, + { key: 'tool', label: 'Tool', hint: 'Does what the model on a job cannot — reading an image, for one.' }, { key: 'tts', label: 'Read aloud', hint: 'Turning a question into speech.' }, { key: 'stt', label: 'Transcription', hint: 'Turning speech into text.' }, ] diff --git a/frontend/src/components/ModelsAdmin.test.jsx b/frontend/src/components/ModelsAdmin.test.jsx index d45aa4c..ae5a329 100644 --- a/frontend/src/components/ModelsAdmin.test.jsx +++ b/frontend/src/components/ModelsAdmin.test.jsx @@ -75,6 +75,16 @@ describe('model selection', () => { expect(await screen.findByText(/Answered/)).toBeInTheDocument() }) + it('names the tool model as a job of its own, and tests it like any other', async () => { + // The model that covers what another model cannot do — reading an image, + // today — is chosen the same way as the tutor's or the extractor's. + api.get.mockResolvedValue({ data: [...MODELS, + { id: 4, name: 'gpt-4.1', model_id: 'gpt-4.1', task: 'tool', is_active: true, is_default: true }] }) + render() + await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tool' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/4/test')) + }) + it('keeps the allow-list folded away, since it is set once', async () => { render() await screen.findByRole('combobox', { name: 'Model for Tutor' }) diff --git a/frontend/src/components/PractiseTopic.jsx b/frontend/src/components/PractiseTopic.jsx index e105026..020d817 100644 --- a/frontend/src/components/PractiseTopic.jsx +++ b/frontend/src/components/PractiseTopic.jsx @@ -50,9 +50,31 @@ export default function PractiseTopic({ article }) { } finally { setBusy(false) } } - // Nothing linked, or the count never came back. Offering a test of no - // questions is worse than offering nothing. - if (!available) return null + // The count never came back. Nothing to say about a number we do not have. + if (available === null) return null + + // No questions, said rather than hidden. It used to render nothing at all, + // which reads as a page that has not finished loading — and a reader who has + // just read the article is the one person entitled to know why there is + // nothing to sit on it. Reading is open to everyone; questions are drawn + // from the bank for the exam you are studying for, and both of those facts + // are worth stating here rather than leaving somebody to infer them. + if (!available) { + return ( +
+

Nothing to practise here yet

+

+ No questions in your bank are linked to this topic. Reading is open to + everyone; questions are drawn from the bank for the exam you are + studying for, so a topic can be readable long before it is examinable. +

+ +
+ ) + } return (
diff --git a/frontend/src/components/PreparedSession.css b/frontend/src/components/PreparedSession.css new file mode 100644 index 0000000..59b9247 --- /dev/null +++ b/frontend/src/components/PreparedSession.css @@ -0,0 +1,53 @@ +/* "Ready for you" — the prepared session and the reasoning behind it. */ + +.ps-panel { + background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; + padding: 16px; margin-bottom: 20px; +} + +.ps-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } +.ps-head h2 { margin: 0; font-size: 1.15rem; font-weight: 650; } +.ps-basis { + font-size: .7rem; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; + padding: 3px 8px; border-radius: 999px; color: var(--primary); + background: color-mix(in srgb, var(--primary) 12%, transparent); +} +.ps-basis.is-cold { color: var(--text-muted); background: var(--border); } + +.ps-summary { margin: 0 0 14px; font-size: .88rem; color: var(--text-muted); } + +.ps-topics { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 11px; } +.ps-topic-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.ps-topic-name { font-size: .9rem; font-weight: 600; overflow-wrap: anywhere; } +.ps-topic-count { font-size: .8rem; color: var(--text-muted); white-space: nowrap; } +.ps-topic-bar { + display: block; height: 4px; margin: 5px 0 4px; border-radius: 2px; + background: var(--border); overflow: hidden; +} +.ps-topic-bar > span { display: block; height: 100%; background: var(--primary); } +.ps-topic-reason { margin: 0; font-size: .8rem; color: var(--text-muted); } + +.ps-foot { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding-top: 13px; border-top: 1px solid var(--border); +} +.ps-count { display: flex; align-items: center; gap: 8px; } +.ps-count > span { font-size: .8rem; font-weight: 600; color: var(--text-muted); } +.ps-count input { + width: 68px; padding: 7px 9px; font: inherit; font-size: .88rem; + color: var(--text); background: var(--input-bg, var(--card-bg)); + border: 1px solid var(--border); border-radius: 8px; +} +/* Takes the slack, so the button stays on the right on a wide card and drops + below on a narrow one rather than being squeezed against the count. */ +.ps-length { flex: 1 1 180px; margin: 0; font-size: .78rem; color: var(--text-subtle, var(--text-muted)); } +.ps-start { white-space: nowrap; } + +.ps-error { margin: 10px 0 0; font-size: .82rem; color: var(--danger-fg, #c0392b); } +.ps-manual { margin: 12px 0 0; font-size: .78rem; color: var(--text-muted); } +.ps-manual a { color: var(--primary); } + +@media (max-width: 520px) { + .ps-foot { align-items: stretch; } + .ps-start { width: 100%; } +} diff --git a/frontend/src/components/PreparedSession.jsx b/frontend/src/components/PreparedSession.jsx new file mode 100644 index 0000000..b9cd2a1 --- /dev/null +++ b/frontend/src/components/PreparedSession.jsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import api from '../api/client' +import { sessionTitle } from '../utils/sessionTitle' +import './PreparedSession.css' + +/** + * One action that produces the session this learner should sit now, and the + * reasoning behind it before they commit to it. + * + * The reasoning is the point. The adaptive picker has existed for a while + * behind a toggle on the custom-test builder, and a learner who cannot see why + * they were given those twenty questions goes back to choosing topics by hand — + * so the plan is shown first, in the learner's own vocabulary, and only then + * started. Everything here is arithmetic over their own answers; nothing is + * generated. + * + * It sits above "Continue your study" rather than below it because this is the + * answer to the question the dashboard exists to ask. Resuming a session left + * open is a recovery path, and it is immediately underneath. + */ + +//: Long enough that typing "30" is one request rather than two, short enough +//: that the plan does not feel stale under the count that produced it. +const RECOUNT_DELAY_MS = 300 + +export default function PreparedSession() { + const navigate = useNavigate() + const [plan, setPlan] = useState(null) + //: What the learner typed, or null while they have not touched it — the + //: length is the plan's decision to make until somebody says otherwise. + //: Held as the raw string so that clearing the box leaves it cleared rather + //: than snapping back to the plan's number under the cursor. + const [chosen, setChosen] = useState(null) + const [loading, setLoading] = useState(true) + const [failed, setFailed] = useState(false) + const [starting, setStarting] = useState(false) + const [error, setError] = useState('') + + const asked = chosen === null ? null : Number.parseInt(chosen, 10) + const valid = asked === null || (Number.isInteger(asked) && asked >= 1 && asked <= 200) + + useEffect(() => { + if (!valid) return undefined + let live = true + const timer = setTimeout(() => { + api.get('/questions/builder/prepared', { params: asked === null ? {} : { count: asked } }) + .then(res => { if (live) { setPlan(res.data); setFailed(false) } }) + // No message. An objective that has not been chosen is already asked + // for by the modal over this page, and an empty bank is not something + // the learner can do anything about from here. + .catch(() => { if (live) setFailed(true) }) + .finally(() => { if (live) setLoading(false) }) + }, asked === null ? 0 : RECOUNT_DELAY_MS) + return () => { live = false; clearTimeout(timer) } + }, [asked, valid]) + + const shown = chosen ?? (plan?.count ?? '') + + const start = useCallback(async () => { + if (starting || !valid || !plan) return + setStarting(true) + setError('') + try { + const res = await api.post('/questions/builder/prepared', { + count: asked || plan.count, + mode: 'learning', + title: sessionTitle('Prepared session'), + }) + navigate(`/study/${res.data.id}?start=1`) + } catch (err) { + const detail = err.response?.data?.detail + setError(typeof detail === 'string' ? detail : 'Could not start that session. Try again.') + setStarting(false) + } + }, [starting, valid, plan, asked, navigate]) + + if (loading || failed || !plan) return null + + return ( +
+
+

Ready for you

+ + {plan.basis === 'cold_start' ? 'Starting point' : 'Personalized'} + +
+ +

{plan.summary}

+ +
    + {plan.topics.map(topic => ( +
  • +
    + {topic.name} + + {topic.count} question{topic.count === 1 ? '' : 's'} + +
    +
  • + ))} +
+ +
+ +

{plan.length_reason}

+ +
+ + {!valid &&

Choose between 1 and 200 questions.

} + {error &&

{error}

} + + {/* The manual builder is not replaced by this — it is what you reach for + when you want something this cannot know you want. */} +

+ Want something specific? Build a test yourself. +

+
+ ) +} diff --git a/frontend/src/components/PreparedSession.test.jsx b/frontend/src/components/PreparedSession.test.jsx new file mode 100644 index 0000000..ae786ca --- /dev/null +++ b/frontend/src/components/PreparedSession.test.jsx @@ -0,0 +1,129 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { beforeEach, expect, it, vi } from 'vitest' +import PreparedSession from './PreparedSession' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } })) + +const PLAN = { + count: 14, + basis: 'personalized', + new_count: 9, + review_count: 5, + available: 2900, + exam_name: 'Pediatrics Boards', + summary: '9 new and 5 due for review, ranked by what would move your score the most.', + length_reason: 'You usually finish 14 questions, so that’s the length.', + topics: [ + { category_id: 1, name: 'Cardiology', count: 8, new_count: 5, review_count: 3, + accuracy: 41, weight: 12, reason: 'Weak area — 41% correct so far' }, + { category_id: 2, name: 'Neonatology', count: 4, new_count: 3, review_count: 1, + accuracy: null, weight: 9, reason: 'Not attempted yet, worth 9% of the exam' }, + { category_id: 3, name: 'Rheumatology', count: 2, new_count: 1, review_count: 1, + accuracy: 78, weight: 2, reason: 'Due for review — last answered 6 weeks ago' }, + ], +} + +const COLD = { + ...PLAN, + count: 20, basis: 'cold_start', new_count: 20, review_count: 0, + summary: "You haven't finished a session yet, so this is an even spread across the exam blueprint rather than a personalized one.", + length_reason: '20 to start with — after 3 more finished sessions this matches your own median.', + topics: [{ category_id: 1, name: 'Cardiology', count: 12, new_count: 12, review_count: 0, + accuracy: null, weight: 60, reason: 'Worth 60% of the exam' }], +} + +const mount = () => render( + + + } /> + Session started} /> + + +) + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ data: PLAN }) +}) + +it('states the plan before anything is started', async () => { + mount() + expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument() + expect(screen.getByText(PLAN.summary)).toBeInTheDocument() + expect(screen.getByText(PLAN.length_reason)).toBeInTheDocument() + + // Every topic, its share of the session, and the one line saying why. + expect(screen.getByText('Cardiology')).toBeInTheDocument() + expect(screen.getByText('8 questions')).toBeInTheDocument() + expect(screen.getByText('Weak area — 41% correct so far')).toBeInTheDocument() + expect(screen.getByText('Due for review — last answered 6 weeks ago')).toBeInTheDocument() + expect(screen.getByLabelText('Number of questions')).toHaveValue(14) + + // The length is the plan's until the learner says otherwise, so the first + // ask carries no count at all. + expect(api.get).toHaveBeenCalledWith('/questions/builder/prepared', { params: {} }) + // Nothing exists yet: the plan is a statement, not a session. + expect(api.post).not.toHaveBeenCalled() +}) + +it('says when it has nothing to personalize from rather than pretending', async () => { + api.get.mockResolvedValue({ data: COLD }) + mount() + expect(await screen.findByText(/haven't finished a session yet/)).toBeInTheDocument() + expect(screen.getByText('Starting point')).toBeInTheDocument() + expect(screen.getByText('Worth 60% of the exam')).toBeInTheDocument() + expect(screen.queryByText('Personalized')).not.toBeInTheDocument() +}) + +it('replans when the learner changes the length', async () => { + mount() + await screen.findByText(PLAN.summary) + const longer = { ...PLAN, count: 30, summary: '21 new and 9 due for review, ranked by what would move your score the most.' } + api.get.mockResolvedValue({ data: longer }) + + const input = screen.getByLabelText('Number of questions') + await userEvent.clear(input) + await userEvent.type(input, '30') + + await waitFor(() => expect(api.get).toHaveBeenLastCalledWith( + '/questions/builder/prepared', { params: { count: 30 } })) + expect(await screen.findByText(longer.summary)).toBeInTheDocument() +}) + +it('starts the session at the length on screen and opens it', async () => { + api.post.mockResolvedValue({ data: { id: 91, questions_count: 14, plan: PLAN } }) + mount() + await screen.findByText(PLAN.summary) + await userEvent.click(screen.getByRole('button', { name: 'Start session' })) + + await waitFor(() => expect(api.post).toHaveBeenCalledWith( + '/questions/builder/prepared', expect.objectContaining({ count: 14, mode: 'learning' }))) + expect(await screen.findByRole('heading', { name: 'Session started' })).toBeInTheDocument() +}) + +it('keeps the manual builder one click away', async () => { + mount() + await screen.findByText(PLAN.summary) + expect(screen.getByRole('link', { name: 'Build a test yourself' })).toHaveAttribute('href', '/study/new') +}) + +it('reports a refused start without navigating away', async () => { + api.post.mockRejectedValue({ response: { data: { detail: 'Choose what you are studying for' } } }) + mount() + await screen.findByText(PLAN.summary) + await userEvent.click(screen.getByRole('button', { name: 'Start session' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Choose what you are studying for') + expect(screen.queryByRole('heading', { name: 'Session started' })).not.toBeInTheDocument() +}) + +it('shows nothing at all when there is no plan to show', async () => { + // An empty bank, or an objective not yet chosen — which the modal over this + // page is already asking for. A second error message here would be noise. + api.get.mockRejectedValue({ response: { status: 400 } }) + const { container } = mount() + await waitFor(() => expect(api.get).toHaveBeenCalled()) + expect(container).toBeEmptyDOMElement() +}) diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 388abe5..6ba9847 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -530,7 +530,7 @@ export default function AnalysisPage() {

Next step: adaptive session

-

Practises what you have not seen first, then the topics you get wrong most.

+

Practices what you have not seen first, then what you get wrong most and what is due again.

{adaptive && (

- Adaptive prefers unanswered questions, then recycles older incorrect ones, and moves between weak + Adaptive fills most of the test with questions you have not seen, gives the rest to ones that are + due again — missed, or last answered long enough ago to be worth checking — and moves between weak areas instead of repeating one.

)} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index f560f2e..a69166b 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import ContinueStudy from '../components/ContinueStudy' +import PreparedSession from '../components/PreparedSession' import { Link } from 'react-router-dom' import api from '../api/client' import LineChart from '../components/LineChart' @@ -79,11 +80,16 @@ export default function DashboardPage() { return (
- {/* The greeting names the page; "continue your study" is the first thing - on it. Below the fold it read as a heading for the wrong section. */} + {/* 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. */} + diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css index 7a45d19..239e868 100644 --- a/frontend/src/pages/LandingPage.css +++ b/frontend/src/pages/LandingPage.css @@ -488,3 +488,57 @@ @media (max-width: 640px) { .lp-modes { gap: 22px; } } + + +/* ── The third panel: what the sitting was for ──────────────────────── + A ring that fills and three bars that grow. No figures and no labels — the + shapes move, and what they are is obvious from where they sit. Six cards of + prose used to say this instead, which is a slower way of showing somebody a + picture of a donut. */ +.lp-pill.is-analysis { background: #e5ecf8; color: #365b8d; } + +.lp-donut { + position: relative; width: 92px; height: 92px; margin: 6px auto 16px; + border-radius: 50%; + background: conic-gradient(var(--primary) 0turn, var(--border) 0turn); +} +/* The hole. A ring drawn with a gradient needs one, or it is a pie. */ +.lp-donut > i { + position: absolute; inset: 13px; border-radius: 50%; + background: var(--card-bg); +} +.lp-donut > span { + position: absolute; inset: 30px; border-radius: 4px; background: var(--border); +} + +.lp-bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } +.lp-bars li { display: flex; align-items: center; gap: 10px; } +.lp-bar-label { flex: 0 0 62px; height: 8px; border-radius: 4px; background: var(--border); } +.lp-bar { flex: 1; height: 10px; border-radius: 5px; background: var(--bg); overflow: hidden; } +.lp-bar > span { display: block; height: 100%; width: 0; background: var(--primary); border-radius: 5px; } +.lp-bar > span.w72 { --to: 72%; } +.lp-bar > span.w48 { --to: 48%; background: #a13c51; } +.lp-bar > span.w86 { --to: 86%; background: #327b64; } + +@media (prefers-reduced-motion: no-preference) { + .lp-modes:not(.is-calm) .lp-donut { animation: lp-fill 9s ease-in-out infinite; } + .lp-modes:not(.is-calm) .lp-bar > span { animation: lp-grow 9s ease-in-out infinite; } +} +/* Without motion the shapes still have to mean something, so they rest full. */ +.lp-modes.is-calm .lp-donut, +.lp-modes:not(.is-calm) .lp-donut { } +.lp-bar > span { width: var(--to); } + +@keyframes lp-fill { + 0%, 10% { background: conic-gradient(var(--primary) 0turn, var(--border) 0turn); } + 55%, 100% { background: conic-gradient(var(--primary) 0.68turn, var(--border) 0.68turn); } +} +@keyframes lp-grow { + 0%, 18% { width: 0; } + 62%, 100% { width: var(--to); } +} + +/* Three panels rather than two, so they may sit narrower. */ +@media (min-width: 1100px) { + .lp-modes { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 7cd340d..bfa6896 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -31,7 +31,7 @@ import './LandingPage.css' const ADAPTIVE_STEPS = [ 'what you have never seen', 'then your weakest topic', - 'then what you got wrong, oldest first', + 'then what you got wrong, most recently first', 'all of it weighted by the real paper', ] @@ -42,60 +42,6 @@ const FIGURES = [ ['articles', 'Articles'], ] -const FEATURES = [ - { - icon: 'timer', - title: 'A player built for how the paper asks', - desc: 'Sit a session as study or as an exam block. Rule an option out and it stays out, highlight the part of the stem that matters, open an attending tip when you are stuck.', - points: [ - 'Per-question timing that only runs while the screen is actually open', - 'A warning before the block ends, not after', - 'Study mode marks as you go; exam mode waits until the end', - ], - }, - { - icon: 'target', - title: 'Sessions that decide themselves', - desc: 'Ask for twenty questions and the bank works out which twenty. Never-seen first, then the topic you are weakest in, then the ones you got wrong, oldest first.', - points: [ - 'Scaled by the share of the real exam each topic is published as carrying', - 'A topic the blueprint does not cover takes the median weight, never zero', - 'It picks what to study, not what the paper is made of', - ], - }, - { - icon: 'chart', - title: 'An analysis that says where you stand', - desc: 'Readiness, score over time, how much of the bank you have covered, and a split of correct, correct after a tip, and incorrect — because those are three different things.', - points: [ - 'A knowledge profile across Articles, Systems and Disciplines', - 'Relevance taken from the ABP content outline, not from our own proportions', - 'Every session reviewable question by question, long after it is closed', - ], - }, - { - icon: 'spark', - title: 'AI Mode, and what it is not allowed to do', - desc: 'Ask it anything and it answers out of your library alone. Retrieval builds the shortlist, the shortlist is the whole prompt, and any citation retrieval did not find is deleted before you see the answer.', - points: [ - 'Every claim arrives with a source you can open and check', - 'Turn any answer straight into a practice session on the same material', - 'No library, no answer — it says so rather than inventing one', - ], - }, - { - icon: 'book', - title: 'Articles that read like a reference', - desc: 'Written material with cross-references between them and tips set inline in the prose, so the explanation is where the question left you rather than three clicks away.', - points: ['Flashcards', 'Courses with modules and lessons', 'Study plans you can hold yourself to'], - }, - { - icon: 'shield', - title: 'A sign-up that reports you to nobody', - desc: 'The challenge in front of registration is self-hosted. It runs here, on this server, and no third party is told that you visited, tried to sign up, or how long you thought about it.', - }, -] - const CLAIMS = [ { title: 'Where the model writes', @@ -120,23 +66,6 @@ const CLINICAL_TILES = [ ['Bedside', 'Emergency care'], ] -const ICONS = { - timer: <>, - target: <>, - chart: <>, - spark: <>, - book: <>, - shield: <>, -} - -function Icon({ name }) { - return ( - - ) -} /** True when the visitor has asked their system for less movement. */ function useCalmMotion() { @@ -612,11 +541,7 @@ function ModeShowcase({ calm }) {
-
-
- Exam - Nothing marked until the block ends, and the clock only runs while you are in it. -
+
+ +
+ +
) } @@ -704,22 +649,7 @@ export default function LandingPage() { {/* ── What it does ───────────────────────────────────────────────────── */}
-
-

Two ways to sit it

-
-
- {FEATURES.map(feature => ( - - -

{feature.title}

-

{feature.desc}

- {feature.points && ( -
    {feature.points.map(point =>
  • {point}
  • )}
- )} -
- ))} -