pdf-quiz-generator/frontend/src/pages/LandingPage.jsx
Daniel a8cc0c40df
Some checks failed
Tests / backend (push) Failing after 9s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Has been cancelled
fix: Sign in goes to the provider, not to a box containing one button
The landing page's Sign in opened a modal whose entire content was a
single "Sign in with PedsHub SSO" link. That is a step that exists to be
clicked through.

Every sign-in control on the page — the header, the hero, the closing
call to action — now goes straight to /api/auth/sso/login when the site
is SSO-only. The modal is still built and still opens on a site that has
a password door, which is the only thing it was ever for.

/login is deliberately left as it is. It renders the one button rather
than redirecting, because it is also where the provider sends somebody
back when sign-in fails — ?error=sso_failed — and a page that redirected
on sight would bounce them into the provider again, forever.

Verified live: one click from the landing page lands on
sso.pedshub.com's flow with the client id, callback, scope, state and
nonce, and no modal is rendered on the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 15:31:50 +02:00

745 lines
32 KiB
JavaScript

import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import useMediaQuery from '../hooks/useMediaQuery'
import Navbar from '../components/Navbar'
import Captcha, { captchaSiteKey } from '../components/Captcha'
import './LandingPage.css'
/**
* The page a stranger lands on.
*
* It describes the product as it is now: a question bank you sit as sessions,
* an analysis that says where you stand, and an assistant that is only allowed
* to answer out of your own library. The previous copy described the one
* feature the site launched with — extracting questions out of a PDF — which
* had stopped being the point some months ago.
*
* Nothing here is decorative motion for its own sake. The line under the
* heading cycles because it has four things to say and room for one; the
* figures count because they are real and worth looking at; the cards arrive
* on scroll because a page this long otherwise lands all at once. Every one of
* those stops dead under `prefers-reduced-motion`, and nothing on the page
* needs movement to be read.
*/
// The four rules of the adaptive picker, in the order it applies them. Kept
// deliberately close to the wording in docs/adaptive-sessions.md: a landing
// page that promises something subtly different from what the algorithm does
// is a landing page that will be wrong the first time anybody checks.
const ADAPTIVE_STEPS = [
'what you have never seen',
'then your weakest topic',
'then what you got wrong, most recently first',
'all of it weighted by the real paper',
]
const FIGURES = [
['questions', 'Questions'],
['topics', 'Topics'],
['systems', 'Systems'],
['articles', 'Articles'],
]
const CLAIMS = [
{
title: 'Where the model writes',
body: 'AI Mode, and the drafting of explanations and tips. In AI Mode its sources are fixed before it starts and checked after it stops — a model cannot cite something it was not given.',
},
{
title: 'Where it does not',
body: 'Choosing your next session is arithmetic on your own answers and a published exam blueprint. It is inspectable, it is the same every time, and no model is asked to guess at it.',
},
{
title: 'Why the difference matters',
body: 'A tool that says "powered by AI" and stops there is asking to be believed. The point of every constraint above is that you can check the claim instead.',
},
]
const CLINICAL_TILES = [
['Well visits', '2wk → 18yr'],
['Milestones', '2mo → 5yr'],
['Vaccines', 'Full schedule'],
['Dosing', 'Weight-based'],
['References', 'Plans + pathways'],
['Bedside', 'Emergency care'],
]
/** True when the visitor has asked their system for less movement. */
function useCalmMotion() {
return useMediaQuery('(prefers-reduced-motion: reduce)')
}
/**
* Adds `is-in` the first time the element is scrolled into view.
*
* Where there is no IntersectionObserver — an older browser, or jsdom under
* test — the element is revealed immediately rather than never. Content that
* depends on an optional API to become visible is content that disappears.
*/
function useReveal() {
const [ref, shown] = useInView()
return [ref, shown ? 'lp-reveal is-in' : 'lp-reveal']
}
/** True once the element has been scrolled into view, and true thereafter. */
function useInView() {
const ref = useRef(null)
const [shown, setShown] = useState(typeof IntersectionObserver === 'undefined')
useEffect(() => {
if (shown || !ref.current) return undefined
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) setShown(true)
}, { rootMargin: '0px 0px -60px 0px' })
observer.observe(ref.current)
return () => observer.disconnect()
}, [shown])
return [ref, shown]
}
function Reveal({ children, className = '' }) {
const [ref, revealClass] = useReveal()
return <div ref={ref} className={`${revealClass} ${className}`.trim()}>{children}</div>
}
/**
* Counts from nothing up to the figure it was given.
*
* The count is animation, so the number the page reports is the target from
* the first render — an assistive reader is given the finished figure, and
* anyone who has asked for calm motion simply sees it.
*/
function useCountUp(target, animate) {
const [shown, setShown] = useState(animate ? 0 : target)
//: Whether this counter has already run. It starts when the figures come
//: into view, which is a state change after mount — without this, anything
//: that re-renders the section afterwards would send the number back to zero
//: and count it up again.
const done = useRef(false)
useEffect(() => {
if (done.current) { setShown(target); return undefined }
if (!animate || typeof requestAnimationFrame !== 'function') { setShown(target); return undefined }
done.current = true
const started = Date.now()
let frame = requestAnimationFrame(function step() {
const progress = Math.min(1, (Date.now() - started) / 1100)
// Eased out, so it arrives at the figure rather than stopping at it.
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))))
if (progress < 1) frame = requestAnimationFrame(step)
})
return () => cancelAnimationFrame(frame)
}, [target, animate])
return shown
}
function Figure({ value, label, animate }) {
const shown = useCountUp(value, animate)
// The name is on the item and the digits inside it are hidden, so a screen
// reader hears "2,924 questions" once instead of a number ticking upwards.
return (
<li className="lp-figure" aria-label={`${value.toLocaleString()} ${label.toLowerCase()}`}>
<span className="lp-figure-value" aria-hidden="true">{shown.toLocaleString()}</span>
<span className="lp-figure-label" aria-hidden="true">{label}</span>
</li>
)
}
/**
* How much there is, asked of the thing that knows.
*
* When the count cannot be fetched the section keeps its heading and loses its
* figures. Rendering zeros instead would be a claim, and a false one: the bank
* is not empty, we just could not reach it.
*/
function PublicFigures() {
const calm = useCalmMotion()
const [stats, setStats] = useState(null)
const [failed, setFailed] = useState(false)
// Counted when the figures are on screen. They used to count on mount, which
// is while the visitor is still reading the hero two screens above — so the
// animation had finished long before anybody could see it, and the numbers
// simply appeared.
const [ref, inView] = useInView()
useEffect(() => {
let live = true
api.get('/public/stats')
.then(res => { if (live) setStats(res.data || null) })
.catch(() => { if (live) setFailed(true) })
return () => { live = false }
}, [])
const figures = FIGURES
.map(([key, label]) => [label, Number(stats?.[key])])
.filter(([, value]) => Number.isFinite(value) && value > 0)
return (
<section className="lp-section lp-section-raised" ref={ref}>
<div className="lp-inner">
<div className="lp-head">
<h2>What is in the bank today</h2>
<p>Counted at the moment you loaded this page, not typed into it last spring.</p>
</div>
<Reveal>
{figures.length > 0 && (
<ul className="lp-figures" aria-label="What is in the bank today">
{figures.map(([label, value]) => (
<Figure key={label} value={value} label={label} animate={!calm && inView} />
))}
</ul>
)}
{failed && (
<p className="lp-figures-missing">
The counts could not be reached just now. Rather than show you a number we
have not checked, here is none.
</p>
)}
</Reveal>
</div>
</section>
)
}
/**
* One line, four things to say.
*
* Under calm motion it settles on the first and stays there; the whole
* sentence is in the document either way, for anyone reading it rather than
* watching it.
*/
function Rotator({ calm }) {
const [index, setIndex] = useState(0)
useEffect(() => {
if (calm) return undefined
const timer = setInterval(() => setIndex(i => (i + 1) % ADAPTIVE_STEPS.length), 2600)
return () => clearInterval(timer)
}, [calm])
return (
<p className="lp-rotator">
<span className="lp-rotator-label">Your next session:</span>
<span className="lp-sr">{ADAPTIVE_STEPS.join(', ')}.</span>
<span className="lp-rotator-word" key={index} aria-hidden="true">{ADAPTIVE_STEPS[index]}</span>
</p>
)
}
// ── Contact form ──────────────────────────────────────────────────────────────
function AuthModal({ onClose }) {
const { login, loginWithToken } = useAuth()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [unverified, setUnverified] = useState(false)
const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
//: The public page's own sign-in box. It is a different component from
//: /login and knew nothing about the provider, so on a site where the only
//: way in is single sign-on it offered an email and a password and a
//: "Forgot password?" — three things that cannot work.
//: Null until the answer arrives. Defaulting to "no provider" drew the
//: email form for a moment on every open and then swapped it for the SSO
//: button — a flash of a way in that does not exist.
const [ssoOnly, setSsoOnly] = useState(null)
const [ssoEnabled, setSsoEnabled] = useState(null)
const [providerName, setProviderName] = useState('single sign-on')
useEffect(() => {
let live = true
// One question, one endpoint. This used to ask the sign-up policy as well,
// for a sign-up that no longer exists.
api.get('/auth/sso/config')
.then(res => {
if (!live) return
setSsoEnabled(res.data?.sso_enabled === true)
setSsoOnly(res.data?.sso_only === true)
setProviderName(res.data?.provider_name || 'single sign-on')
})
.catch(() => { if (live) { setSsoEnabled(false); setSsoOnly(false) } })
return () => { live = false }
}, [])
const reset = () => { setError(''); setUnverified(false); setResendSent(false) }
const handleLogin = async (e) => {
e.preventDefault()
setError(''); setUnverified(false)
setLoading(true)
try {
await login(email, password)
onClose()
navigate('/')
} catch (err) {
if (err.response?.status === 403) setUnverified(true)
else setError(err.response?.data?.detail || 'Login failed')
} finally { setLoading(false) }
}
const resendVerification = async () => {
setResending(true)
try { await api.post('/auth/resend-verification', { email }) } catch {}
setResendSent(true)
setResending(false)
}
return (
<div onClick={onClose} className="lp-modal-backdrop">
<div onClick={e => e.stopPropagation()} className="lp-modal">
<button onClick={onClose} className="lp-modal-close" aria-label="Close"></button>
{ssoEnabled === null && <div className="loading"><div className="spinner" /></div>}
{/* The provider, first, and on an SSO-only site the only thing here. */}
{ssoEnabled === true && (
<>
<a href="/api/auth/sso/login" className="btn btn-primary btn-block"
style={{ display: 'block', textAlign: 'center', textDecoration: 'none' }}>
Sign in with {providerName}
</a>
{ssoOnly === false && <p className="lp-modal-or">or sign in with email</p>}
</>
)}
{/* Login form */}
{ssoOnly === false && (
<>
{unverified && !resendSent && (
<div className="lp-unverified">
<p>Email not verified. Check your inbox.</p>
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
{resending ? 'Sending…' : 'Resend verification email'}
</button>
</div>
)}
{resendSent && <div className="alert alert-success">Verification email sent.</div>}
{error && <div className="alert alert-error">{error}</div>}
<form aria-label="Sign in" onSubmit={handleLogin}>
<div className="form-group">
<label htmlFor="modal-login-email">Email</label>
<input id="modal-login-email" type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label htmlFor="modal-login-password">Password</label>
<input id="modal-login-password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button className="btn btn-primary btn-block" disabled={loading}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
<div className="lp-forgot">
<Link to="/forgot-password" onClick={onClose}>Forgot password?</Link>
</div>
</>
)}
</div>
</div>
)
}
/**
* The two modes, played out rather than described.
*
* "Six things, each of which is doing something specific about a real problem
* with revising" was a sentence asking to be trusted. These are the same two
* claims shown happening: a study question marks itself the moment it is
* answered and opens its explanation, and an exam block keeps its answers to
* itself while the clock runs down.
*
* Everything moves on CSS keyframes off one shared loop, so there is no timer
* to drift and nothing to tear down. Under calm motion the animation is simply
* not applied: both panels render in their finished state, which is the state
* worth reading anyway.
*/
function ModeShowcase({ calm }) {
const cls = `lp-modes${calm ? ' is-calm' : ''}`
return (
<div className={cls}>
<figure className="lp-mode lp-mode-study" aria-label="A study question marking itself and opening its explanation">
{/* No caption. The panel is a picture of a study question marking
itself, which is the whole of what a caption would have said — and
a screen reader is told that by the figure's own label rather than
by a line of prose under a mimed screen. */}
<div className="lp-screen" aria-hidden="true">
<div className="lp-screen-bar">
<span className="lp-pill is-study">Study</span>
<span className="lp-line w40" />
</div>
<div className="lp-screen-body">
<span className="lp-line w95" />
<span className="lp-line w88" />
<span className="lp-line w60" />
<ul className="lp-opts">
<li><i>A</i><span className="lp-line w55" /></li>
<li className="is-picked is-wrong"><i>B</i><span className="lp-line w42" /></li>
<li className="is-right"><i>C</i><span className="lp-line w62" /></li>
<li><i>D</i><span className="lp-line w38" /></li>
</ul>
<div className="lp-explain">
<span className="lp-line w90" />
<span className="lp-line w72" />
</div>
</div>
</div>
</figure>
<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>
{/* A real countdown rather than a still of one: the seconds
column scrolls, which is the only thing an exam block does
while you are reading it. */}
<span className="lp-clock">
<b>00</b>:<b>5</b>
<b className="lp-tick"><i>9</i><i>8</i><i>7</i><i>6</i><i>5</i></b>
</span>
</div>
<div className="lp-screen-split">
<ul className="lp-rail">
{Array.from({ length: 8 }, (_, i) => (
<li key={i} className={i < 3 ? 'is-done' : i === 3 ? 'is-here' : ''}>{i + 1}</li>
))}
</ul>
<div className="lp-screen-body">
<span className="lp-line w92" />
<span className="lp-line w78" />
<ul className="lp-opts is-plain">
<li><i>A</i><span className="lp-line w58" /></li>
<li className="is-picked"><i>B</i><span className="lp-line w44" /></li>
<li><i>C</i><span className="lp-line w64" /></li>
<li><i>D</i><span className="lp-line w36" /></li>
</ul>
</div>
</div>
<div className="lp-screen-foot">
<span className="lp-line w28" />
<span className="lp-end">End Block</span>
</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>
)
}
/**
* The deck studio.
*
* The third thing PedsHub does is make slides — a talk built from the same
* evidence the bank is written from, laid out on a template, with the figures
* dropped in, exported as PowerPoint. That is a hard thing to say in a
* sentence and an easy thing to show, so this says nothing: a slide assembles
* itself, the deck beside it lights up as each one lands, and the eye works
* out what it is watching before any caption could have told it.
*
* Three slides share one 21s loop, each phase-shifted by a third of it with a
* negative delay, so there is no JavaScript clock and nothing to unwind on
* unmount. Every part inside a slide is on the same loop and the same delay
* plus its own small `--d`, which is what staggers the build: the panel, then
* the heading, then the figure, then the numbers.
*
* Under calm motion the loop is not applied at all and the three slides are
* laid out side by side, finished — which is the state a deck is meant to be
* looked at in anyway.
*/
function SlideStudio({ calm }) {
return (
<div className={`lp-studio${calm ? ' is-calm' : ''}`}>
{/* aria-hidden throughout: this is a picture of a deck being made, not a
deck. A screen reader is told what it is by the figure's label and
skips the mime. */}
<figure className="lp-stage" aria-label="A slide deck being built: a title, a figure with a growth curve, and a slide of results, then exported">
<div className="lp-deck" aria-hidden="true">
{/* The app around the slide. Without it three slides in a row are
three pictures; with it they are a deck being made. */}
<div className="lp-chrome">
<span className="lp-chrome-dots"><i /><i /><i /></span>
<span className="lp-chrome-tools"><i /><i /><i /></span>
<span className="lp-chrome-export" />
</div>
<div className="lp-canvas">
{/* 1 — the opener. */}
<div className="lp-slide is-title" style={{ '--i': 0 }}>
<span className="lp-sl-wash" style={{ '--d': '0.15s' }} />
<span className="lp-sl-mark" style={{ '--d': '1.0s' }} />
<div className="lp-sl-titleblock">
<span className="lp-sl-rule" style={{ '--d': '0.45s' }} />
<span className="lp-sl-h1" style={{ '--d': '0.65s' }} />
<span className="lp-sl-h2" style={{ '--d': '0.85s' }} />
</div>
<div className="lp-sl-hero" style={{ '--d': '0.55s' }}>
<svg viewBox="0 0 60 80" preserveAspectRatio="none" role="presentation">
<circle className="lp-hero-a" cx="30" cy="26" r="17" />
<path className="lp-hero-b" d="M8 78 C 10 52, 22 44, 30 44 C 38 44, 50 52, 52 78 Z" />
</svg>
</div>
<span className="lp-sl-foot" style={{ '--d': '1.25s' }} />
</div>
{/* 2 — evidence and a figure that draws itself. */}
<div className="lp-slide is-figure" style={{ '--i': 1 }}>
<span className="lp-sl-eyebrow" style={{ '--d': '0.25s' }} />
<span className="lp-sl-title" style={{ '--d': '0.4s' }} />
<div className="lp-sl-cols">
<div className="lp-sl-copy">
<span style={{ '--d': '0.65s' }} />
<span style={{ '--d': '0.78s' }} />
<span style={{ '--d': '0.91s' }} />
<span style={{ '--d': '1.04s' }} />
<span className="lp-sl-bullet" style={{ '--d': '1.2s' }} />
</div>
<div className="lp-sl-figure" style={{ '--d': '0.7s' }}>
<svg viewBox="0 0 120 84" preserveAspectRatio="none" role="presentation">
<g className="lp-sl-grid">
<line x1="6" y1="20" x2="114" y2="20" /><line x1="6" y1="42" x2="114" y2="42" />
<line x1="6" y1="64" x2="114" y2="64" />
</g>
<path className="lp-sl-band" d="M6 62 C 34 54, 66 34, 114 12 L114 32 C 66 52, 34 66, 6 74 Z" />
<path className="lp-sl-curve" d="M6 68 C 34 60, 66 40, 114 18" pathLength="100" />
<circle className="lp-sl-dot" cx="42" cy="57" r="2.8" style={{ '--d': '1.9s' }} />
<circle className="lp-sl-dot" cx="78" cy="39" r="2.8" style={{ '--d': '2.0s' }} />
<circle className="lp-sl-dot is-last" cx="114" cy="18" r="3.6" style={{ '--d': '2.1s' }} />
</svg>
</div>
</div>
<span className="lp-sl-cite" style={{ '--d': '1.35s' }} />
</div>
{/* 3 — the numbers. */}
<div className="lp-slide is-data" style={{ '--i': 2 }}>
<span className="lp-sl-eyebrow" style={{ '--d': '0.25s' }} />
<span className="lp-sl-title" style={{ '--d': '0.4s' }} />
<div className="lp-sl-data">
<ul className="lp-sl-bars">
<li style={{ '--h': '46%', '--d': '0.7s' }} />
<li style={{ '--h': '72%', '--d': '0.82s' }} />
<li style={{ '--h': '58%', '--d': '0.94s' }} />
<li style={{ '--h': '92%', '--d': '1.06s' }} />
</ul>
<div className="lp-sl-ring" style={{ '--d': '0.85s' }}>
<svg viewBox="0 0 44 44" role="presentation">
<circle className="lp-sl-ring-track" cx="22" cy="22" r="18" />
<circle className="lp-sl-ring-fill" cx="22" cy="22" r="18" pathLength="100" />
<circle className="lp-sl-ring-second" cx="22" cy="22" r="18" pathLength="100" />
</svg>
</div>
</div>
<ul className="lp-sl-legend">
<li style={{ '--d': '1.25s' }} /><li style={{ '--d': '1.33s' }} /><li style={{ '--d': '1.41s' }} />
</ul>
</div>
{/* The pointer that puts things where they go, and the sheen that
crosses a slide once it is standing. */}
<span className="lp-sl-cursor" aria-hidden="true" />
<span className="lp-sl-sheen" aria-hidden="true" />
</div>
</div>
</figure>
{/* The deck as it fills: three thumbnails, the one being built lit. */}
<ul className="lp-strip" aria-hidden="true">
<li className="lp-thumb is-title" style={{ '--i': 0 }}>
<span className="lp-th-wash" /><span className="lp-th-bar w70" /><span className="lp-th-bar w44" />
</li>
<li className="lp-thumb is-figure" style={{ '--i': 1 }}>
<span className="lp-th-bar w52" />
<div className="lp-th-split"><span className="lp-th-lines" /><span className="lp-th-fig" /></div>
</li>
<li className="lp-thumb is-data" style={{ '--i': 2 }}>
<span className="lp-th-bar w52" />
<div className="lp-th-cols"><i style={{ '--h': '46%' }} /><i style={{ '--h': '72%' }} /><i style={{ '--h': '58%' }} /><i style={{ '--h': '92%' }} /></div>
</li>
</ul>
</div>
)
}
// ── Main landing page ─────────────────────────────────────────────────────────
export default function LandingPage() {
const { user } = useAuth()
const calm = useCalmMotion()
//: One door. Accounts are made at the identity provider, so there is no
//: second mode for this to be in.
const [authOpen, setAuthOpen] = useState(false)
//: Where "Sign in" goes. On a site whose only way in is the provider there
//: is nothing to ask first, so the button is the provider's door rather than
//: a box containing one button — a modal whose entire content is a link is a
//: step that exists to be clicked through.
const [ssoOnly, setSsoOnly] = useState(false)
useEffect(() => {
let live = true
api.get('/auth/sso/config')
.then(res => {
if (live) setSsoOnly(res.data?.sso_enabled === true && res.data?.sso_only === true)
})
.catch(() => {})
return () => { live = false }
}, [])
const signIn = () => {
if (ssoOnly) window.location.href = '/api/auth/sso/login'
else setAuthOpen(true)
}
return (
<div className="lp-page">
{authOpen && !ssoOnly && <AuthModal onClose={() => setAuthOpen(false)} />}
<Navbar onSignIn={signIn} />
{/* ── Hero ───────────────────────────────────────────────────────────── */}
<section className="lp-hero">
<div className="lp-hero-inner">
{/* Deliberately not "pediatric". The domain already says whose site
this is, and the steps are coming — a line that names one exam
would have to be rewritten the week the next one is added. */}
<span className="lp-eyebrow">Clinical boards and exam preparation</span>
<h1>Sit the paper<br /><em>before you sit the paper.</em></h1>
<p className="lp-lead">
A question bank you take as timed blocks or as study, an analysis that
tells you where you actually stand, and an assistant that is only
permitted to answer out of your own library.
</p>
<Rotator calm={calm} />
<div className="lp-cta">
{user ? <>
<Link to="/" className="btn btn-primary">Dashboard</Link>
<Link to="/study/new" className="btn lp-cta-ghost">Start a session</Link>
<Link to="/sessions" className="btn lp-cta-ghost">Your analysis</Link>
</> : <>
{/* One button. "Create an account" led to a form that no longer
exists — an account is made by following an invitation from
the identity provider, not from here. */}
<button onClick={signIn} className="btn btn-primary">Sign in</button>
</>}
</div>
</div>
</section>
<PublicFigures />
{/* ── What it does ───────────────────────────────────────────────────── */}
<section className="lp-section">
<div className="lp-inner">
<ModeShowcase calm={calm} />
</div>
</section>
{/* ── Slides ─────────────────────────────────────────────────────────
Wordless on purpose. It is a deck being built out of the same
evidence, and watching it land says more than a paragraph claiming
it would. The one link is navigation, not explanation. */}
<section className="lp-section lp-section-raised">
<div className="lp-inner">
<SlideStudio calm={calm} />
<div className="lp-studio-cta">
{/* Straight to My Resources, which is where a deck is made — the
scribe reads the hash and opens there after sign-in. */}
<a href="https://app.pedshub.com/#resources" target="_blank" rel="noopener noreferrer"
className="btn lp-cta-ghost">Make a deck </a>
</div>
</div>
</section>
{/* ── Where the AI is ────────────────────────────────────────────────── */}
<section className="lp-section lp-section-dark">
<div className="lp-inner">
<div className="lp-head">
<span className="lp-eyebrow">Yes, it is AI</span>
<h2>And here is exactly where</h2>
<p>
The interesting part of a claim is what would show it to be wrong. So: this
is which parts of the site a language model touches, and which parts it is
kept out of.
</p>
</div>
<div className="lp-claims">
{CLAIMS.map(claim => (
<Reveal key={claim.title} className="lp-claim">
<h3>{claim.title}</h3>
<p>{claim.body}</p>
</Reveal>
))}
</div>
</div>
</section>
{/* ── Clinical tools ─────────────────────────────────────────────────── */}
<section className="lp-section lp-section-dark">
<div className="lp-inner lp-split">
<div>
<span className="lp-eyebrow">Also from PedsHub</span>
<h2>Clinical tools</h2>
<p>
The same material, pointed at the ward instead of the exam: well visit
planning from newborn to adolescence, developmental milestones, the full
vaccine schedule with a catch-up planner, weight-based dosing, and
emergency pathways for sepsis, status epilepticus, RSI, burns and
anaphylaxis.
</p>
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer"
className="btn btn-primary">
Open clinical tools
</a>
</div>
<div className="lp-tiles">
{CLINICAL_TILES.map(([label, sub]) => (
<div key={label} className="lp-tile">
<div className="lp-tile-label">{label}</div>
<div className="lp-tile-sub">{sub}</div>
</div>
))}
</div>
</div>
</section>
<footer className="lp-footer">
<div className="lp-footer-inner">
<span className="lp-footer-brand">
🏥 PedsHub<span>© {new Date().getFullYear()}</span>
</span>
<div className="lp-footer-links">
<button onClick={signIn}>Sign In</button>
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer">Clinical Tools</a>
</div>
</div>
</footer>
</div>
)
}