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
{children}
} /** * 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 (
  • ) } /** * 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 (

    What is in the bank today

    Counted at the moment you loaded this page, not typed into it last spring.

    {figures.length > 0 && (
      {figures.map(([label, value]) => (
      ))}
    )} {failed && (

    The counts could not be reached just now. Rather than show you a number we have not checked, here is none.

    )}
    ) } /** * 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 (

    Your next session: {ADAPTIVE_STEPS.join(', ')}.

    ) } // ── 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 (
    e.stopPropagation()} className="lp-modal"> {ssoEnabled === null &&
    } {/* The provider, first, and on an SSO-only site the only thing here. */} {ssoEnabled === true && ( <> Sign in with {providerName} {ssoOnly === false &&

    or sign in with email

    } )} {/* Login form */} {ssoOnly === false && ( <> {unverified && !resendSent && (

    Email not verified. Check your inbox.

    )} {resendSent &&
    Verification email sent.
    } {error &&
    {error}
    }
    setEmail(e.target.value)} required autoFocus />
    setPassword(e.target.value)} required />
    Forgot password?
    )}
    ) } /** * 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 (
    {/* 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. */}
    ) } /** * 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 (
    {/* 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. */}
    {/* The deck as it fills: three thumbnails, the one being built lit. */}
    ) } // ── 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 (
    {authOpen && !ssoOnly && setAuthOpen(false)} />} {/* ── Hero ───────────────────────────────────────────────────────────── */}
    {/* 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. */} Clinical boards and exam preparation

    Sit the paper
    before you sit the paper.

    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.

    {user ? <> Dashboard Start a session Your analysis : <> {/* 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. */} }
    {/* ── What it does ───────────────────────────────────────────────────── */}
    {/* ── 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. */}
    {/* Straight to My Resources, which is where a deck is made — the scribe reads the hash and opens there after sign-in. */} Make a deck ↗
    {/* ── Where the AI is ────────────────────────────────────────────────── */}
    Yes, it is AI

    And here is exactly where

    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.

    {CLAIMS.map(claim => (

    {claim.title}

    {claim.body}

    ))}
    {/* ── Clinical tools ─────────────────────────────────────────────────── */}
    Also from PedsHub

    Clinical tools

    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.

    Open clinical tools ↗
    {CLINICAL_TILES.map(([label, sub]) => (
    {label}
    {sub}
    ))}
    ) }