feat: the landing page shows the product instead of describing it

Six cards of prose sat under two mock players that already showed the thing.
"A player built for how the paper asks. Sit a session as study or as an exam
block…" is the picture above it, written out again, and a stranger reads
neither. They are gone, and so is the heading over the band — it named what the
pictures show.

A third panel joins them: an analysis with a ring that fills and three bars
that grow. No figures and no labels, because a number on a landing page is a
number nobody checks; the shapes move and where they sit says what they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 15:40:37 +02:00
parent 83f6cb15cf
commit 0a331cf4eb
13 changed files with 443 additions and 136 deletions

View file

@ -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())

View file

@ -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.' },
]

View file

@ -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(<ModelsAdmin />)
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(<ModelsAdmin />)
await screen.findByRole('combobox', { name: 'Model for Tutor' })

View file

@ -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 (
<section className="article-practise is-empty">
<h3>Nothing to practise here yet</h3>
<p>
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.
</p>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => navigate('/study/new')}>
Build a session on something else
</button>
</section>
)
}
return (
<section className="article-practise">

View file

@ -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%; }
}

View file

@ -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 (
<section className="ps-panel" aria-labelledby="ps-heading">
<div className="ps-head">
<h2 id="ps-heading">Ready for you</h2>
<span className={`ps-basis${plan.basis === 'cold_start' ? ' is-cold' : ''}`}>
{plan.basis === 'cold_start' ? 'Starting point' : 'Personalized'}
</span>
</div>
<p className="ps-summary">{plan.summary}</p>
<ul className="ps-topics">
{plan.topics.map(topic => (
<li key={topic.category_id ?? topic.name}>
<div className="ps-topic-line">
<span className="ps-topic-name">{topic.name}</span>
<span className="ps-topic-count">
{topic.count} question{topic.count === 1 ? '' : 's'}
</span>
</div>
<span className="ps-topic-bar" aria-hidden="true">
<span style={{ width: `${Math.round((100 * topic.count) / plan.count)}%` }} />
</span>
<p className="ps-topic-reason">{topic.reason}</p>
</li>
))}
</ul>
<div className="ps-foot">
<label className="ps-count">
<span>Questions</span>
<input type="number" min="1" max="200" step="1" value={shown}
aria-label="Number of questions"
onChange={e => setChosen(e.target.value)} />
</label>
<p className="ps-length">{plan.length_reason}</p>
<button type="button" className="btn btn-primary ps-start"
disabled={starting || !valid} onClick={start}>
{starting ? 'Starting…' : 'Start session'}
</button>
</div>
{!valid && <p className="ps-error">Choose between 1 and 200 questions.</p>}
{error && <p className="ps-error" role="alert">{error}</p>}
{/* The manual builder is not replaced by this it is what you reach for
when you want something this cannot know you want. */}
<p className="ps-manual">
Want something specific? <Link to="/study/new">Build a test yourself</Link>.
</p>
</section>
)
}

View file

@ -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 thats 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(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<PreparedSession />} />
<Route path="/study/:id" element={<h1>Session started</h1>} />
</Routes>
</MemoryRouter>
)
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()
})

View file

@ -530,7 +530,7 @@ export default function AnalysisPage() {
<div className="an-nextstep">
<div className="an-nextstep-text">
<h2>Next step: adaptive session</h2>
<p>Practises what you have not seen first, then the topics you get wrong most.</p>
<p>Practices what you have not seen first, then what you get wrong most and what is due again.</p>
</div>
<div className="an-nextstep-controls">
<label>

View file

@ -197,6 +197,12 @@
/* ── Practise this topic ──────────────────────────────────────────── */
.article-practise { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 16px; }
/* Quieter than the offer it replaces: it is an explanation, not a control. */
.article-practise.is-empty h3 { margin: 0 0 6px; font-size: 0.95rem; }
.article-practise.is-empty p {
margin: 0 0 12px; max-width: 62ch;
font-size: 0.86rem; line-height: 1.6; color: var(--text-muted);
}
.article-practise-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.article-practise-head h3 { margin: 0; font-size: 1rem; }
.article-practise-count { font-size: .78rem; color: var(--text-muted); }

View file

@ -306,7 +306,8 @@ export default function CustomQuizPage() {
</div>
{adaptive && (
<p className="custom-test-note custom-test-card-pad">
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.
</p>
)}

View file

@ -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 (
<div>
{/* 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. */}
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
</div>
{/* What to do now, before what was left half-done: the session that is
waiting is a recovery path, and it is directly underneath. */}
<PreparedSession />
<ContinueStudy />
<MyNote variant="card" />

View file

@ -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)); }
}

View file

@ -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: <><circle cx="12" cy="13" r="8" /><path d="M12 9v4l2.5 2.5M9 1h6" /></>,
target: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="0.6" /></>,
chart: <><path d="M3 21h18M7 17V9M12 17V4M17 17v-6" /></>,
spark: <><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z" /><path d="M18 16.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" /></>,
book: <><path d="M4 4.5A1.5 1.5 0 015.5 3H19v18H5.5A1.5 1.5 0 014 19.5z" /><path d="M8 7h7M8 11h7" /></>,
shield: <><path d="M12 3l7 3v6c0 4.2-2.9 7.9-7 9-4.1-1.1-7-4.8-7-9V6z" /><path d="M9 12l2 2 4-4" /></>,
}
function Icon({ name }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{ICONS[name]}
</svg>
)
}
/** True when the visitor has asked their system for less movement. */
function useCalmMotion() {
@ -612,11 +541,7 @@ function ModeShowcase({ calm }) {
</div>
</figure>
<figure className="lp-mode lp-mode-exam">
<figcaption>
<strong>Exam</strong>
<span>Nothing marked until the block ends, and the clock only runs while you are in it.</span>
</figcaption>
<figure className="lp-mode lp-mode-exam" aria-label="An exam block with its clock running and nothing marked">
<div className="lp-screen is-exam" aria-hidden="true">
<div className="lp-screen-bar is-exam">
<span className="lp-block">Item 3 of 40</span>
@ -651,6 +576,26 @@ function ModeShowcase({ calm }) {
</div>
</div>
</figure>
<figure className="lp-mode lp-mode-analysis" aria-label="An analysis filling in as answers are recorded">
<div className="lp-screen" aria-hidden="true">
<div className="lp-screen-bar">
<span className="lp-pill is-analysis">Analysis</span>
<span className="lp-line w40" />
</div>
<div className="lp-screen-body">
{/* A ring that fills and three bars that grow. Nothing here claims a
number: the shapes move, and what they are is obvious from where
they sit. */}
<div className="lp-donut"><span /><i /></div>
<ul className="lp-bars">
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w72" /></span></li>
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w48" /></span></li>
<li><span className="lp-bar-label" /><span className="lp-bar"><span className="w86" /></span></li>
</ul>
</div>
</div>
</figure>
</div>
)
}
@ -704,22 +649,7 @@ export default function LandingPage() {
{/* ── What it does ───────────────────────────────────────────────────── */}
<section className="lp-section">
<div className="lp-inner">
<div className="lp-head">
<h2>Two ways to sit it</h2>
</div>
<ModeShowcase calm={calm} />
<div className="lp-grid">
{FEATURES.map(feature => (
<Reveal key={feature.title} className="lp-card">
<span className="lp-card-mark"><Icon name={feature.icon} /></span>
<h3>{feature.title}</h3>
<p>{feature.desc}</p>
{feature.points && (
<ul>{feature.points.map(point => <li key={point}>{point}</li>)}</ul>
)}
</Reveal>
))}
</div>
</div>
</section>