diff --git a/app/account/page.tsx b/app/(app)/account/page.tsx similarity index 100% rename from app/account/page.tsx rename to app/(app)/account/page.tsx diff --git a/app/assessment/page.tsx b/app/(app)/assessment/page.tsx similarity index 100% rename from app/assessment/page.tsx rename to app/(app)/assessment/page.tsx diff --git a/app/curriculum/page.tsx b/app/(app)/curriculum/page.tsx similarity index 100% rename from app/curriculum/page.tsx rename to app/(app)/curriculum/page.tsx diff --git a/app/dashboard/DashboardSkeleton.tsx b/app/(app)/dashboard/DashboardSkeleton.tsx similarity index 100% rename from app/dashboard/DashboardSkeleton.tsx rename to app/(app)/dashboard/DashboardSkeleton.tsx diff --git a/app/dashboard/loading.tsx b/app/(app)/dashboard/loading.tsx similarity index 100% rename from app/dashboard/loading.tsx rename to app/(app)/dashboard/loading.tsx diff --git a/app/dashboard/page.tsx b/app/(app)/dashboard/page.tsx similarity index 100% rename from app/dashboard/page.tsx rename to app/(app)/dashboard/page.tsx diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..5d58b33 --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,15 @@ +/** + * Layout for the signed-in product surface. + * + * Holds the reading-width container every app page expects. The landing page + * lives in `(marketing)` instead, where it is free to run full-bleed. + */ +export default function AppLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( +
+ {children} +
+ ); +} diff --git a/app/learn/[lessonId]/page.tsx b/app/(app)/learn/[lessonId]/page.tsx similarity index 100% rename from app/learn/[lessonId]/page.tsx rename to app/(app)/learn/[lessonId]/page.tsx diff --git a/app/login/page.tsx b/app/(app)/login/page.tsx similarity index 100% rename from app/login/page.tsx rename to app/(app)/login/page.tsx diff --git a/app/onboarding/page.tsx b/app/(app)/onboarding/page.tsx similarity index 100% rename from app/onboarding/page.tsx rename to app/(app)/onboarding/page.tsx diff --git a/app/topics/page.tsx b/app/(app)/topics/page.tsx similarity index 100% rename from app/topics/page.tsx rename to app/(app)/topics/page.tsx diff --git a/app/tutor/page.tsx b/app/(app)/tutor/page.tsx similarity index 100% rename from app/tutor/page.tsx rename to app/(app)/tutor/page.tsx diff --git a/app/(marketing)/_components/AdaptivePathDemo.tsx b/app/(marketing)/_components/AdaptivePathDemo.tsx new file mode 100644 index 0000000..bd18e5c --- /dev/null +++ b/app/(marketing)/_components/AdaptivePathDemo.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Check, RotateCcw, Lock, Dot } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +/** + * The landing page's centerpiece: the adaptive path, moving. + * + * Describing "the curriculum reorders itself as your mastery shifts" in a + * paragraph is forgettable; watching a lesson flip to needs-review and climb + * back up the list is not. Each stage mirrors a real transition from + * `lib/domain/adapt.ts` + `mastery.ts` — seeded mastery skips a lesson, EWMA + * promotes one, a weak answer demotes another and hoists it. + * + * Rows are absolutely positioned and moved by `translateY`, so a reorder + * animates instead of snapping. Pure CSS/state — no animation library. + */ + +const ROW_PX = 56; + +type Status = "mastered" | "progress" | "review" | "locked" | "available"; + +interface Stage { + /** Lesson ids, top to bottom. */ + order: string[]; + status: Record; + label: string; + caption: string; +} + +const LESSONS: Record = { + io: "Reading CSVs with pandas", + idx: "Indexing and selection", + grp: "GroupBy aggregations", + join: "Merging and joins", + ts: "Time series resampling", +}; + +const STAGES: Stage[] = [ + { + label: "After the quiz", + order: ["io", "idx", "grp", "join", "ts"], + status: { + io: "mastered", + idx: "available", + grp: "locked", + join: "locked", + ts: "locked", + }, + caption: + "Your intake quiz already showed you can load a file. That lesson starts mastered — and gets skipped.", + }, + { + label: "You practice", + order: ["io", "idx", "grp", "join", "ts"], + status: { + io: "mastered", + idx: "mastered", + grp: "progress", + join: "locked", + ts: "locked", + }, + caption: + "Mastery is a rolling average over every practice answer — not a checkbox you tick.", + }, + { + label: "A shaky answer", + order: ["io", "idx", "grp", "join", "ts"], + status: { + io: "mastered", + idx: "mastered", + grp: "review", + join: "available", + ts: "locked", + }, + caption: + "Two weak answers on group-bys drop that score below the review threshold.", + }, + { + label: "The path rewrites", + order: ["grp", "join", "ts", "io", "idx"], + status: { + grp: "review", + join: "available", + ts: "available", + io: "mastered", + idx: "mastered", + }, + caption: + "So the weak lesson is hoisted back to the front, prerequisites still respected, and mastered work sinks.", + }, +]; + +const STATUS_LABEL: Record = { + mastered: "Mastered", + progress: "In progress", + review: "Needs review", + locked: "Locked", + available: "Up next", +}; + +function StatusIcon({ status }: { status: Status }) { + if (status === "mastered") return ; + if (status === "review") return ; + if (status === "locked") return ; + return ; +} + +export function AdaptivePathDemo() { + const [stage, setStage] = useState(0); + const [auto, setAuto] = useState(true); + const current = STAGES[stage]; + + useEffect(() => { + if (!auto) return; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const t = setInterval(() => setStage((s) => (s + 1) % STAGES.length), 3400); + return () => clearInterval(t); + }, [auto]); + + function pick(i: number) { + setAuto(false); // a deliberate click wins over the carousel + setStage(i); + } + + return ( +
+ {/* Window chrome — frames it as a product surface, not a diagram. */} +
+ + Your path + + + / Python for data analysis + +
+ +
+
+ {Object.entries(LESSONS).map(([id, title]) => { + const pos = current.order.indexOf(id); + const status = current.status[id]; + const dim = status === "locked"; + return ( +
+ + + + + {title} + + + {STATUS_LABEL[status]} + +
+ ); + })} +
+ + {/* Stage caption + scrubber */} +
+
+ + {String(stage + 1).padStart(2, "0")} + + {current.label} +
+

+ {current.caption} +

+
+ {STAGES.map((s, i) => ( +
+
+
+
+ ); +} diff --git a/app/(marketing)/_components/Reveal.tsx b/app/(marketing)/_components/Reveal.tsx new file mode 100644 index 0000000..d159524 --- /dev/null +++ b/app/(marketing)/_components/Reveal.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +/** + * Reveals its children once they scroll into view. + * + * The observer flips a `data-shown` attribute directly on the node rather than + * going through state: a landing page has a dozen of these, and none of them + * needs a re-render to change opacity. It also keeps the effect free of the + * setState React 19's `react-hooks/set-state-in-effect` rule flags. + * + * The animation itself is CSS (`.reveal` in globals.css), which is also where + * it's turned off under `prefers-reduced-motion`. + */ +export function Reveal({ + children, + delay = 0, + className, + as: Tag = "div", +}: { + children: React.ReactNode; + /** Stagger, in ms, for items revealed as a group. */ + delay?: number; + className?: string; + as?: "div" | "section" | "li"; +}) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const io = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return; + el.dataset.shown = "true"; + io.disconnect(); // one-way: revealed stays revealed + }, + // Fire a little before the element is fully on screen. + { rootMargin: "0px 0px -12% 0px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, []); + + return ( + } + data-shown="false" + style={delay ? { transitionDelay: `${delay}ms` } : undefined} + className={cn("reveal", className)} + > + {children} + + ); +} diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..f5002a5 --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,11 @@ +/** + * Layout for the signed-out marketing surface. + * + * Deliberately unconstrained: the landing page composes its own full-bleed + * sections and sets its own rhythm, so no max-width or padding is imposed here. + */ +export default function MarketingLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return
{children}
; +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx new file mode 100644 index 0000000..d2a5e50 --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { ArrowRight, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { PageLoader } from "@/components/ui/loading-ring"; +import { useSession } from "@/components/SessionProvider"; +import { Reveal } from "./_components/Reveal"; +import { AdaptivePathDemo } from "./_components/AdaptivePathDemo"; + +// WebGL background — client-only. three.js can't run during SSR, and this +// keeps the ~three.js chunk out of the initial bundle until the page mounts. +const Dither = dynamic(() => import("@/components/Dither"), { ssr: false }); + +/** Shared section heading: mono index, display title, optional standfirst. */ +function SectionHead({ + index, + title, + lead, +}: { + index: string; + title: string; + lead?: string; +}) { + return ( + + + {index} + +

{title}

+ {lead && ( +

+ {lead} +

+ )} +
+ ); +} + +const STEPS = [ + { + n: "01", + t: "Describe", + d: "Say what you want to learn, however vaguely. A clarity loop asks follow-ups until the goal is sharp enough to plan against.", + }, + { + n: "02", + t: "Diagnose", + d: "A short adaptive quiz spans difficulty bands to find where you actually are — with an honest “I don't know” on every question.", + }, + { + n: "03", + t: "Generate", + d: "Modules and lessons are written for that level and sorted by prerequisite, so nothing arrives before the thing it depends on.", + }, + { + n: "04", + t: "Adapt", + d: "Every practice answer moves a rolling mastery score. The path reorders, skips what you've proven, and resurfaces what slipped.", + }, +]; + +const TUTOR_THREAD = [ + { from: "learner", text: "Just tell me why my group-by returns NaN." }, + { + from: "tutor", + text: "Not yet — you're close. Before the group-by runs, what does that column's dtype say? And what does sum() do with a value it can't add?", + }, + { from: "learner", text: "…oh. It's an object column, not a float." }, + { + from: "tutor", + text: "That's it. So what's the one call you'd put in front of the group-by?", + }, +] as const; + +export default function Home() { + const router = useRouter(); + const { me } = useSession(); + + useEffect(() => { + if (me) router.replace("/topics"); + }, [me, router]); + + // `undefined` is the session still resolving; a signed-in learner gets the + // redirect above, so only the signed-out marketing page falls through. + if (me === undefined || me) return ; + + return ( + <> + {/* Full-bleed animated background — fixed behind all content. Cool-slate + waves sit inside the ~200° theme; the gradient darkens downward so + copy stays legible and the field blends into the page background. + Mouse interaction is off since it sits behind content. */} +
+ + {/* Two overlays. The vertical one tames the bright top of the field so + the headline keeps its contrast, and stops at /92 rather than solid + so the lower sections still sit on a living surface instead of flat + black. The radial one vignettes the corners inward. */} +
+
+
+ + {/* ── Hero ─────────────────────────────────────────────────────────── */} +
+
+ +

+ Adaptive learning, paced for you +

+
+ +

+ A path drawn from +
where you{" "} + + actually are + {/* Hand-drawn underline: draws itself once, then rests. */} + + + + + . +

+
+ +

+ Most courses start at lesson one whoever you are. LearnPath + diagnoses your level first, writes a curriculum around it, and + keeps rewriting that curriculum as your mastery shifts. +

+
+ +
+ + +
+
+ +
    + {[ + "Adaptive intake quiz", + "Prerequisite-ordered path", + "AI-authored lessons", + "Socratic tutor", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+
+
+ + {/* ── 01 · The adaptive mechanic, demonstrated ─────────────────────── */} +
+
+ + + + +
+
+ + {/* ── 02 · How it works ───────────────────────────────────────────── */} +
+
+ +
    + {STEPS.map((s, i) => ( + +
    +
    + + {s.n} + + +
    + {s.t} + + {s.d} + +
    +
    + ))} +
+
+
+ + {/* ── 03 · Inside a lesson ────────────────────────────────────────── */} +
+
+ + +
+
+ + Analogy + +

+ A group-by is a mail + sorter: every row drops into the pigeonhole named by its key, + and only once every row has landed do you weigh each pile. +

+
+ +
+ + Example + +
+                  {`sales.groupby("region")["revenue"].sum()`}
+                
+
+ + {/* The practice card, mirroring the real one in the lesson page. */} +
+ + Practice + +

+ Which call returns one row per region? +

+
+ {[ + { t: 'sales["region"].sum()', ok: false }, + { t: 'sales.groupby("region").sum()', ok: true }, + { t: 'sales.sort_values("region")', ok: false }, + ].map((c) => ( +
+ {c.ok ? ( + + ) : ( + + )} + {c.t} +
+ ))} +
+

+ Multiple choice is graded on the spot — no waiting on a server + round trip. +

+
+
+
+
+
+ + {/* ── 04 · The tutor ──────────────────────────────────────────────── */} +
+
+ + +
+
+ + Tutor + + + / grounded in your own lessons + +
+ +
+ {TUTOR_THREAD.map((m, i) => + m.from === "learner" ? ( +

+ {m.text} +

+ ) : ( +

+ {m.text} +

+ ), + )} +
+ +

+ + Never hands over the answer +

+
+
+
+
+ + {/* ── Close ───────────────────────────────────────────────────────── */} +
+
+ +

+ Start where you are, not at lesson one. +

+

+ Describe a topic and take the quiz — the first path is generated + from there. +

+ +
+
+
+ +
+
+ + LearnPath + + + Adaptive learning, paced for you + +
+
+ + ); +} diff --git a/app/globals.css b/app/globals.css index 88dce0c..94eeea6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -79,6 +79,37 @@ } } +/* -------------------------------------------------------------------------- + Landing-page motion. Both are decorative and both are disabled under + `prefers-reduced-motion` at the bottom of this file. + -------------------------------------------------------------------------- */ + +/* Scroll reveal. `Reveal` (app/(marketing)/_components) flips `data-shown` via + IntersectionObserver; the transition lives here so it costs no re-render. */ +.reveal { + opacity: 0; + transform: translateY(0.75rem); + transition: + opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.7s cubic-bezier(0.22, 1, 0.36, 1); +} +.reveal[data-shown="true"] { + opacity: 1; + transform: none; +} + +/* Hero underline that draws itself once, just after the headline lands. */ +.underline-draw { + stroke-dasharray: 320; + stroke-dashoffset: 320; + animation: draw-underline 1.1s cubic-bezier(0.65, 0, 0.35, 1) 0.5s forwards; +} +@keyframes draw-underline { + to { + stroke-dashoffset: 0; + } +} + /* ========================================================================== STUDIO PALETTE — LearnPath (Monochromatic) A deep slate-cyan room. Single hue family (~200°), chroma kept ≤0.02 @@ -189,8 +220,18 @@ } @media (prefers-reduced-motion: reduce) { - /* The sweep is decorative; the text beside it already says what's happening. */ - .animate-sweep { + /* All three are decorative: the sweep has text beside it saying what's + happening, and the landing page reads identically without its motion. */ + .animate-sweep, + .underline-draw { animation: none; } + .underline-draw { + stroke-dashoffset: 0; + } + .reveal { + opacity: 1; + transform: none; + transition: none; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 71493a8..3a3d582 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -69,9 +69,9 @@ export default function RootLayout({