+ );
+}
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. */}
+
+
+
+ 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.
+
+
+
+ {/* ── Close ───────────────────────────────────────────────────────── */}
+
+
+
+
+ Start where you are, not at lesson one.
+
+
+ Describe a topic and take the quiz — the first path is generated
+ from there.
+
+
+
+
+
+
+
+ >
+ );
+}
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({
-
- {children}
-
+ {/* The wrapper lives in the route-group layouts: `(app)` sets
+ the reading-width container, `(marketing)` stays full-bleed. */}
+ {children}
diff --git a/app/page.tsx b/app/page.tsx
deleted file mode 100644
index dce5129..0000000
--- a/app/page.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import dynamic from "next/dynamic";
-import { ArrowRight } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { PageLoader } from "@/components/ui/loading-ring";
-import { useSession } from "@/components/SessionProvider";
-
-// 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 });
-
-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 — scoped to this page, 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. */}
-
-
-
-
-
-
-
- Adaptive learning, paced for you
-
-
- A path drawn from where you actually are.
-
-
- Describe what you want to learn. LearnPath diagnoses your level with a
- short adaptive quiz, generates a curriculum that respects what you
- already know, and reorders itself as your mastery shifts. A Socratic
- tutor stays beside you — guiding, never handing over the answer.
-
-
-
-
- Sign in to pick up where you left off.
-
-
-
- {/* Three quiet pillars */}
-
- {[
- {
- n: "01",
- t: "Diagnose",
- d: "A short adaptive quiz finds your actual level — no guessing.",
- },
- {
- n: "02",
- t: "Generate",
- d: "Modules and lessons are written for you, in the right order.",
- },
- {
- n: "03",
- t: "Adapt",
- d: "Mastery moves the path. Review surfaces what you forget.",
- },
- ].map((p) => (
-
-
- {p.n}
-
- {p.t}
-
- {p.d}
-
-
- ))}
-
-
- >
- );
-}
diff --git a/summary.md b/summary.md
index 70cfc4b..15921b3 100644
--- a/summary.md
+++ b/summary.md
@@ -184,8 +184,17 @@ lib/
client/api.ts Browser fetch helper (throws ApiClientError with status).
instrumentation.ts Next boot hook → starts the lesson worker (nodejs runtime only).
app/api/ 18 route handlers (see README table).
-app/ Client pages: page, login, onboarding, assessment, curriculum,
- learn/[lessonId], dashboard, tutor.
+app/(app)/ Signed-in pages: login, onboarding, assessment, curriculum,
+ learn/[lessonId], dashboard, topics, tutor, account. Its layout
+ holds the max-w-4xl reading container.
+app/(marketing)/ The landing page (URL stays `/` — route groups aren't in the path).
+ Its layout imposes no width, so the page runs full-bleed.
+ _components/ Landing-only UI; `_` keeps the folder non-routable.
+ Reveal.tsx Scroll reveal — flips `data-shown` via IntersectionObserver
+ (no setState, so no re-render and no React 19 effect-rule fight).
+ AdaptivePathDemo.tsx The animated path: rows are absolutely positioned and moved by
+ translateY, so a reorder animates. Stages mirror real
+ adapt.ts/mastery.ts transitions — keep them honest.
components/ Nav.tsx + ui.tsx (Button/Card/Badge/Spinner/ProgressBar/ErrorText).
test/ Vitest: unit/ (pure domain/server/auth/http logic) + integration/
(live-LLM agent tests, self-skip without GEMINI_API_KEY).