diff --git a/frontend/e2e/landing-drag-field.spec.ts b/frontend/e2e/landing-drag-field.spec.ts new file mode 100644 index 00000000..c0db5c7b --- /dev/null +++ b/frontend/e2e/landing-drag-field.spec.ts @@ -0,0 +1,383 @@ +/** + * The landing page's draggable course clusters must read as part of the page. + * + * Promoted from a real regression: the clusters were a layer floating over the + * site rather than in it. Four symptoms, all pinned here because unit tests + * in jsdom can only assert the geometry the fixture itself supplies — whether + * a real browser's layout, sticky positioning and compositor agree is only + * answerable here. + * + * 1. a cluster inside a pinned act drifted up to 150px against the scroll; + * 2. the sim kept integrating while the page scrolled, so every node + * wandered 20-50px of its own accord under a moving page; + * 3. nodes were clamped to their svg's viewBox, walling the drag ~900px + * sideways and ~1600px up/down of the cluster's home; + * 4. the field's sticky box was a different height from its act's stage, so + * it released a full viewport later — 882px of the copy scrolling away + * while the clusters stayed welded to the top of the screen. That one + * survived the first three fixes and every test written for them, + * because they all measured a cluster against its own field rather than + * against the page. + * + * Public surface: no auth, no DB. `test` comes from @playwright/test rather + * than support/fixtures precisely because there is no row to reset — the + * fixtures' per-test TRUNCATE would be pure cost here. The storageState the + * project config injects is dropped for the same reason: this is what a + * signed-out visitor sees. + * + * The field is hidden below 1024px by a media query in globals.css, so every + * test here pins a desktop viewport. + */ +import { expect, test, type Page } from "@playwright/test"; + +test.use({ storageState: { cookies: [], origins: [] }, viewport: { width: 1440, height: 900 } }); + +/** + * The envelope a placed node's breathing stays inside — `PLACED_SWAY` is a + * third of the free amplitude. Wide enough for the sway, far short of the + * distance that would mean it had wandered off the spot it was left on. + */ +const SWAY_PX = 12; + +/** Where a ring sits on screen, and what the page is doing underneath it. */ +interface Probe { + x: number; + y: number; + scrollY: number; +} + +/** Settle the landing: the intro overlay, the hero cascade, and the first + * frames of the sim all have to be behind us before anything is measured. */ +async function openLanding(page: Page): Promise { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("[data-dragnode]").first()).toBeAttached({ timeout: 15_000 }); + await page.waitForTimeout(2_500); + return page.evaluate(() => document.documentElement.scrollHeight); +} + +/** + * The cluster the drag journeys use: id 4, in the static `faq` section. + * + * Named rather than discovered. Picking "whatever ring is on screen" looks + * more robust and is the opposite: the idle breathing drift moves nodes + * ~20px, which is enough to change which of them clears a visibility filter, + * so the journey grabs a different node from run to run and fails for reasons + * that have nothing to do with the code. + * + * `faq` specifically, because these journeys need room on all four sides. + * The `cta` clusters sit ~120px above the end of the document, so there is + * nothing left to autoscroll into and a 300px scroll assertion cannot be met. + */ +const CLUSTER = "4"; +/** A satellite, not the course puck: the link spring pulls hardest on these. */ +const SATELLITE = 1; + +/** Scroll the named cluster to a fixed place in the viewport. */ +async function centreCluster(page: Page, id = CLUSTER): Promise { + const found = await page.evaluate((cid) => { + const cluster = document.querySelector(`[data-dragnode="${cid}"]`); + if (!cluster) return false; + window.scrollBy(0, cluster.getBoundingClientRect().top - 380); + return true; + }, id); + expect(found, `cluster ${id} should exist`).toBe(true); + await page.waitForTimeout(700); +} + +/** + * Tag one ring of the named cluster and return its centre. + * + * Preference order starting at `index`, but only a ring that HIT-TESTS TO + * ITSELF is taken. The rings of a cluster sit within a few px of each other + * and the later-painted one wins: tagging ring 1 while the pointer actually + * grabs ring 3 produces a test that watches the wrong node get towed along by + * the link force, and reads that as the drag failing. + */ +async function ringOf(page: Page, index = SATELLITE, id = CLUSTER): Promise { + const found = await page.evaluate(({ cid, i }) => { + const rings = Array.from(document.querySelectorAll(`[data-dragnode="${cid}"] [data-sim]`)); + const order = [rings[i], ...rings].filter(Boolean); + for (const ring of order) { + const b = ring.getBoundingClientRect(); + const x = b.left + b.width / 2; + const y = b.top + b.height / 2; + if (document.elementFromPoint(x, y) !== ring) continue; + ring.setAttribute("data-e2e-probe", "1"); + return { x, y, scrollY: window.scrollY }; + } + return null; + }, { cid: id, i: index }); + expect(found, `cluster ${id} should expose a grabbable ring`).not.toBeNull(); + expect(found!.y, "the ring should be well inside the viewport").toBeGreaterThan(120); + expect(found!.y).toBeLessThan(780); + return found!; +} + +async function probe(page: Page): Promise { + return page.evaluate(() => { + const b = document.querySelector("[data-e2e-probe]")!.getBoundingClientRect(); + return { x: b.left + b.width / 2, y: b.top + b.height / 2, scrollY: window.scrollY }; + }); +} + +test("nodes do not move of their own accord while the page scrolls", async ({ page }) => { + const height = await openLanding(page); + + // Sample every frame, in the page, so the measurement never depends on + // round-trip timing. Each ring is measured against ITS OWN cluster, which + // separates "the sim moved it" from "the section it belongs to is sticky". + for (const fraction of [0.5, 0.8, 0.9]) { + await page.evaluate((y) => window.scrollTo(0, y), Math.round(height * fraction)); + await page.waitForTimeout(700); + + await page.evaluate(() => { + (window as never as { __frames: unknown[] }).__frames = []; + const w = window as never as { __frames: unknown[]; __raf: number }; + const tick = () => { + const rings: Array<{ k: string; dx: number; dy: number }> = []; + document.querySelectorAll("[data-dragnode]").forEach((cluster) => { + const cb = cluster.getBoundingClientRect(); + cluster.querySelectorAll("[data-sim]").forEach((r, i) => { + const b = r.getBoundingClientRect(); + if (b.top > -600 && b.top < window.innerHeight + 600) { + rings.push({ + k: `${cluster.getAttribute("data-dragnode")}:${i}`, + dx: b.left - cb.left, dy: b.top - cb.top, + }); + } + }); + }); + w.__frames.push({ y: window.scrollY, rings }); + w.__raf = requestAnimationFrame(tick); + }; + w.__raf = requestAnimationFrame(tick); + }); + + for (let i = 0; i < 30; i++) await page.mouse.wheel(0, 40); + + const result = await page.evaluate(() => { + const w = window as never as { + __frames: Array<{ y: number; rings: Array<{ k: string; dx: number; dy: number }> }>; + __raf: number; + }; + cancelAnimationFrame(w.__raf); + // Path length, not frame-to-frame delta. The wander this catches is + // ~0.15px per frame and only becomes visible by accumulating -- a + // per-frame threshold loose enough to survive one noisy sample is + // loose enough to miss the whole regression. + const travelled = new Map(); + let compared = 0; + for (let i = 1; i < w.__frames.length; i++) { + if (w.__frames[i].y === w.__frames[i - 1].y) continue; // page was still + const now = Object.fromEntries(w.__frames[i].rings.map((r) => [r.k, r])); + for (const before of w.__frames[i - 1].rings) { + const after = now[before.k]; + if (!after) continue; + const step = Math.hypot(after.dx - before.dx, after.dy - before.dy); + travelled.set(before.k, (travelled.get(before.k) ?? 0) + step); + compared++; + } + } + return { + worst: Math.max(0, ...travelled.values()), + compared, + scrolled: w.__frames.at(-1)!.y - w.__frames[0].y, + }; + }); + + expect(result.scrolled, "the page should actually have scrolled").toBeGreaterThan(200); + expect(result.compared, "rings should have been on screen to compare").toBeGreaterThan(20); + // Welded: a node's offset within its own cluster is frozen while the page + // moves, so it travels nowhere at all. This was tens of px before the fix. + expect(result.worst).toBeLessThan(2); + } +}); + +test("a cluster holds still against its act, through the pin and the release", async ({ page }) => { + await openLanding(page); + + // act-tutor is a 340vh section holding one sticky stage; clusters 2 and 3 + // live in it. Walk the whole act, including the point where the stage stops + // sticking -- which is exactly where the field used to part company with + // the copy, having been given a sticky box of a different height. + const act = await page.evaluate(() => { + const section = document.getElementById("act-tutor")!; + return { top: section.offsetTop, height: section.offsetHeight }; + }); + + await page.evaluate((y) => window.scrollTo(0, y), act.top - 200); + await page.waitForTimeout(700); + + await page.evaluate(() => { + const w = window as never as { __act: unknown[]; __raf: number }; + w.__act = []; + const section = document.getElementById("act-tutor")!; + // The stage is the sticky child carrying the COPY, identified by the act's + // heading. Identifying it as "the sticky child without clusters in it" + // silently resolves to the drag field once the sim has re-homed the + // clusters into its overlay — which turns this whole journey into a + // comparison of the cluster against its own anchor, i.e. zero by + // construction, passing against the very markup it exists to catch. + const stage = Array.from(section.children).find( + (el) => getComputedStyle(el).position === "sticky" + && !el.classList.contains("drag-field") + && !el.querySelector(".drag-field") + && el.querySelector("h2"), + ); + if (!stage) throw new Error("act-tutor has no sticky stage carrying an h2"); + const tick = () => { + const rows: Array<{ k: string; d: number }> = []; + for (const id of ["2", "3"]) { + const cluster = document.querySelector(`[data-dragnode="${id}"]`); + if (!cluster) continue; + const b = cluster.getBoundingClientRect(); + if (b.top < -2500 || b.top > window.innerHeight + 2500) continue; + rows.push({ k: id, d: b.top - stage.getBoundingClientRect().top }); + } + w.__act.push({ y: window.scrollY, rows }); + w.__raf = requestAnimationFrame(tick); + }; + w.__raf = requestAnimationFrame(tick); + }); + + // Enough ticks to cross the whole act and come out the far side. + const ticks = Math.ceil((act.height + 600) / 60); + for (let i = 0; i < ticks; i++) await page.mouse.wheel(0, 60); + await page.waitForTimeout(200); + + const result = await page.evaluate(() => { + const w = window as never as { + __act: Array<{ y: number; rows: Array<{ k: string; d: number }> }>; + __raf: number; + }; + cancelAnimationFrame(w.__raf); + const seen = new Map(); + for (const frame of w.__act) { + for (const row of frame.rows) { + if (!seen.has(row.k)) seen.set(row.k, []); + seen.get(row.k)!.push(row.d); + } + } + const spreads = [...seen.entries()].map(([k, ds]) => ({ + k, samples: ds.length, spread: Math.max(...ds) - Math.min(...ds), + })); + return { + spreads, + scrolled: w.__act.at(-1)!.y - w.__act[0].y, + }; + }); + + expect(result.scrolled, "should have crossed the whole act").toBeGreaterThan(act.height * 0.7); + expect(result.spreads.length, "both act-tutor clusters should have been seen").toBe(2); + for (const { k, samples, spread } of result.spreads) { + expect(samples, `cluster ${k} should have been sampled`).toBeGreaterThan(30); + // Welded to the act: the cluster's offset from the stage never changes, + // whether the stage is pinned or scrolling away. This was 882px. + expect(spread, `cluster ${k} drifted from its act's stage`).toBeLessThan(2); + } +}); + +test("a held node reaches the far edge of the viewport", async ({ page }) => { + await openLanding(page); + await centreCluster(page); + const ring = await ringOf(page, 0); + + // Toward whichever edge is further away. Clusters sit near one margin or + // the other, so a fixed direction measures the short trip half the time. + const width = page.viewportSize()!.width; + const target = ring.x < width / 2 ? width - 6 : 6; + + await page.mouse.move(ring.x, ring.y); + await page.mouse.down(); + await page.mouse.move(target, ring.y, { steps: 30 }); + await page.waitForTimeout(200); + + const at = await probe(page); + // The old viewBox clamp walled this ~900px from the cluster's home, well + // short of the far edge on a 1440px viewport. + expect(Math.abs(ring.x - at.x)).toBeGreaterThan(1000); + expect(Math.abs(at.x - target)).toBeLessThan(60); + await page.mouse.up(); +}); + +test("a held node can be carried down the document and back up", async ({ page }) => { + await openLanding(page); + await centreCluster(page); + const ring = await ringOf(page, 0); + await page.mouse.move(ring.x, ring.y); + await page.mouse.down(); + + // Held in the bottom band, the page scrolls under the node — which is what + // lets it leave its own section at all. + await page.mouse.move(ring.x, 885); + await page.waitForTimeout(1_500); + const down = await probe(page); + expect(down.scrollY - ring.scrollY).toBeGreaterThan(300); + expect(down.y, "still under the cursor, not lost off screen").toBeGreaterThan(700); + + await page.mouse.move(ring.x, 15); + await page.waitForTimeout(1_500); + const up = await probe(page); + expect(up.scrollY).toBeLessThan(down.scrollY - 300); + expect(up.y).toBeLessThan(200); + + // Parked away from the bands, the page must sit still. + await page.mouse.move(ring.x, 450); + const parked = await probe(page); + await page.waitForTimeout(900); + expect((await probe(page)).scrollY).toBe(parked.scrollY); + await page.mouse.up(); +}); + +test("a dropped node stays where it was put, and scrolls with the page", async ({ page }) => { + await openLanding(page); + await centreCluster(page); + const ring = await ringOf(page); + await page.mouse.move(ring.x, ring.y); + await page.mouse.down(); + await page.mouse.move(400, 300, { steps: 20 }); + await page.mouse.up(); + + const dropped = await probe(page); + await page.waitForTimeout(4_000); + const settled = await probe(page); + // Inside its sway, not frozen on the spot: a placed node keeps a third of + // the breathing drift, so it lives where it was left rather than dying + // there. SWAY_PX is the envelope that buys. + expect(Math.hypot(settled.x - dropped.x, settled.y - dropped.y)).toBeLessThan(SWAY_PX); + + // Placed, not detached: it belongs to the page and moves with it. + await page.evaluate(() => window.scrollBy(0, 300)); + await page.waitForTimeout(600); + const scrolled = await probe(page); + expect(Math.hypot(scrolled.x - settled.x, scrolled.y - (settled.y - 300))) + .toBeLessThan(SWAY_PX); +}); + +test("a short drag stays put instead of crawling home", async ({ page }) => { + // There is no rejoin radius any more. A drop within 70px of a node's home + // used to re-float it, so most drags — which are short — crept back to + // where they started: 14px of travel still climbing 4s after a 40px drag, + // against 0px for a 90px one. Every drop places the node now. + await openLanding(page); + await centreCluster(page); + const ring = await ringOf(page); + + await page.mouse.move(ring.x, ring.y); + await page.mouse.down(); + await page.mouse.move(ring.x + 34, ring.y - 22, { steps: 15 }); + await page.mouse.up(); + await page.waitForTimeout(500); + + const dropped = await probe(page); + // Short enough that the old radius would have reeled it in, and it moved. + const moved = Math.hypot(dropped.x - ring.x, dropped.y - ring.y); + expect(moved).toBeLessThan(70); + expect(moved).toBeGreaterThan(2); + + // Past a full breathing period: still there, still breathing. Dead still + // was the other bug — a dropped node used to stop moving entirely. + await page.waitForTimeout(5_000); + const later = await probe(page); + expect(Math.hypot(later.x - dropped.x, later.y - dropped.y)).toBeLessThan(SWAY_PX); +}); diff --git a/frontend/e2e/landing-graph.spec.ts b/frontend/e2e/landing-graph.spec.ts deleted file mode 100644 index 53e33cd4..00000000 --- a/frontend/e2e/landing-graph.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Journey — the landing page's interactive knowledge graph (#344). - * - * The graph is the page's whole argument, so this pins that it renders, that - * picking a course swaps it, and that engaging with it recedes the - * instructional copy. Test mode (NEXT_PUBLIC_TEST_MODE=1, baked into the - * Playwright profile build) parks the helical assembly animation, so every - * assertion below runs against the fully laid-out frame — no waiting on - * requestAnimationFrame. - * - * Selectors: testids from the component - * (src/components/marketing/graph/KnowledgeGraphDemo.tsx) — `landing-graph` - * for the section, `landing-graph-chip-` for the course picker, - * `landing-graph-node-` for graph nodes, `landing-graph-copy` for the - * instructional heading block, `landing-graph-blurb` for the hover blurb. - * Course/node ids and the `ma-vectors` blurb text are seeded fixture data - * (src/components/marketing/graph/courseGraphs.ts) — `cs210` is the default - * (first) course, `ma242` the second. `AssemblingGraph` is keyed by - * `graph.id`, so switching courses remounts it and the previous course's - * node elements leave the DOM entirely — asserted below via `toHaveCount(0)` - * rather than a visibility check. - */ -import { expect, test } from './support/fixtures'; - -test('landing graph renders, swaps by course, and fades its copy on engagement', async ({ page }) => { - await page.goto('/'); - - const section = page.getByTestId('landing-graph'); - await section.scrollIntoViewIfNeeded(); - await expect(section).toBeVisible(); - - // Parked frame: the first course's root node is laid out and visible. - await expect(page.getByTestId('landing-graph-node-cs-root')).toBeVisible(); - - // Picking another course swaps the graph. - await page.getByTestId('landing-graph-chip-ma242').click(); - await expect(page.getByTestId('landing-graph-node-ma-root')).toBeVisible(); - await expect(page.getByTestId('landing-graph-node-cs-root')).toHaveCount(0); - - // Engaging recedes the copy. - const copy = page.getByTestId('landing-graph-copy'); - await expect(copy).toHaveAttribute('data-engaged', 'false'); - await page.getByTestId('landing-graph-node-ma-vectors').hover(); - await expect(copy).toHaveAttribute('data-engaged', 'true'); - await expect(page.getByTestId('landing-graph-blurb')).toContainText('Span, basis'); -}); - -/** - * #344 review #3 — the demo shipped with a single 900×560 viewBox at every - * width. Playwright's default 1280×720 viewport cannot see the consequence, - * which is why the journey above passed: at a 390px phone the section's content - * box is ~332px, so the SVG rendered at a uniform 0.38 scale — 4.6 CSS px - * concept labels and a 213px-tall smudge, on the device most marketing traffic - * arrives on. The component now swaps to a phone view below the mobile - * breakpoint, whose frame is fitted to its content (`-32 40 429 230`, #344 - * visual 3): 0.77 scale ⇒ 12.4 CSS px labels, 23.2px dots, 178px tall. The - * first two clear the bars below by 1.4px and 3.2px. The bracket is still - * two-sided — a bigger ring keeps labels off their neighbours, a smaller frame - * keeps the type legible — but the upward fan put its two deep arms 90° apart - * and symmetric about the vertical, which widened the window: the first two - * margins were 0.2px and 0.9px under the downward tree. See `MOBILE_VIEW` in - * `layout.ts`. - * - * THE HEIGHT BAR MOVED 260 → 170, and it is not a legibility bar being lowered. - * `viewBox` is fitted to the entry SWEEP, not to the settled drawing, or the - * assembly gets clipped mid-flight (#344 review #4). While `helixEntry` turned - * 1.5 times, that sweep was nearly a disc and the frame nearly square, so this - * phone frame was 273px tall around a drawing 156px tall — 118px of empty paper - * that the ">260px" bar was, in effect, asserting. The entry is a quarter turn - * now: the frame is 178px, the DRAWING inside it is 161px — bigger than before — - * and the honest version of this bar (the drawing's rendered height, not the - * frame's) lives in `layout.test.ts`, where the settled extents can be measured - * exactly rather than inferred from a bounding rect. - * - * Asserting in CSS pixels — what a visitor's eye actually gets — rather than on - * the viewBox attribute, so a different fix that reaches the same legibility - * still passes. - */ -test('the graph stays legible at a 390px phone viewport (#344)', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await page.goto('/'); - - const section = page.getByTestId('landing-graph'); - await section.scrollIntoViewIfNeeded(); - await expect(page.getByTestId('landing-graph-node-cs-root')).toBeVisible(); - - const metrics = await page.getByTestId('landing-graph-svg').evaluate((el) => { - const svg = el as unknown as SVGSVGElement; - const rect = svg.getBoundingClientRect(); - const label = svg.querySelector('text')!; - // A non-root node — the small dots are what actually got unreadable. - const dot = svg.querySelector( - '[data-testid="landing-graph-node-cs-arrays"] circle', - )!; - // The SVG is width:100%, so every user unit renders at this scale. - const scale = rect.width / svg.viewBox.baseVal.width; - return { - heightPx: rect.height, - labelPx: parseFloat(getComputedStyle(label).fontSize) * scale, - dotDiameterPx: 2 * dot.r.baseVal.value * scale, - }; - }); - - expect(metrics.labelPx, 'concept labels in CSS px').toBeGreaterThanOrEqual(11); - expect(metrics.dotDiameterPx, 'non-root node diameter in CSS px').toBeGreaterThanOrEqual(20); - expect(metrics.heightPx, 'rendered graph height in CSS px').toBeGreaterThan(170); -}); - -test('the deleted scroll section is gone and the CTA still routes', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('#how-it-works')).toHaveCount(0); - await expect(page.locator('#features')).toHaveCount(0); - await expect(page.getByTestId('signin-trigger')).toBeVisible(); -}); diff --git a/frontend/e2e/public-seo.spec.ts b/frontend/e2e/public-seo.spec.ts index 64afed5d..3d5cf59f 100644 --- a/frontend/e2e/public-seo.spec.ts +++ b/frontend/e2e/public-seo.spec.ts @@ -57,34 +57,44 @@ test("landing page ships social cards and a canonical URL (#169)", async ({ requ }); /** - * The actual SSR guard (#344 review #5). + * The actual SSR guard (#344 review #5, retargeted for the v5 landing). * * The assertions above were previously described as "the guard on not breaking * SSR", but they aren't: og:image / twitter:card / canonical are emitted by the - * Metadata API whether or not any component server-renders. The landing page - * mounts `KnowledgeGraphDemo` through `next/dynamic` with SSR left ON, and - * `ssr: false` is the first thing anyone will reach for the moment a hydration - * warning appears there — it would leave every other spec green while silently - * dropping the section's copy out of the crawled HTML, which is the entire - * reason that component carries the `usePrefersReducedMotion` machinery. - * `landing-graph.spec.ts` runs post-hydration and cannot see the difference. + * Metadata API whether or not any component server-renders. * - * So assert on the RAW response body, before any JS runs. With `ssr: false` - * the dynamic import renders only its loading placeholder (an empty - * `
`), and every assertion below fails. + * This test originally guarded `KnowledgeGraphDemo`, mounted through + * `next/dynamic`, against someone reaching for `ssr: false` the moment a + * hydration warning appeared — which would leave every other spec green while + * silently dropping the section's copy out of the crawled HTML. The v5 landing + * replaced that component, but the failure mode is unchanged and is in fact + * sharper: v5 is a client component whose visuals are canvas and WebGL, so + * essentially all of its crawlable payload is the prose asserted below. Wrap + * the page (or the hero) in a `ssr: false` dynamic import and this is the only + * spec that notices. + * + * Assert on the RAW response body, before any JS runs. Note what is NOT + * asserted: the wordmark and tagline are empty on the server because they + * scramble in on the client, so `aria-label` on the h1 carries the accessible + * name and is checked here in its place. */ -test("the knowledge-graph section is in the server-rendered HTML (#344)", async ({ request }) => { +test("the landing page's copy is in the server-rendered HTML (#344)", async ({ request }) => { const res = await request.get("/"); expect(res.status()).toBe(200); const html = await res.text(); - expect(html, "graph section markup must be server-rendered").toContain( - 'data-testid="landing-graph"', - ); - expect(html, "the section's copy must be crawlable").toContain( - "Pick a course. Watch it grow.", + // the wordmark scrambles in client-side, so only its accessible name is server-rendered + expect(html, "the wordmark's accessible name must survive SSR").toContain('aria-label="Sapling"'); + + expect(html, "the lede is the page's primary SEO payload").toContain( + "Sapling reads your whole course", ); - // Concept labels are the section's actual SEO payload. - expect(html).toContain('data-testid="landing-graph-node-cs-root"'); - expect(html).toContain("Recursion"); + expect(html).toContain("It works from your own coursework, not the open web"); + + // the three key columns state what the product does + expect(html).toContain("Ingest"); + expect(html).toContain("Recall"); + expect(html).toContain("Every concept linked to the ones it rests on"); + + expect(html, "the beta offer must be crawlable").toContain("Free through beta."); }); diff --git a/frontend/eslint-suppressions.json b/frontend/eslint-suppressions.json index b2c77bdc..7ec1fbc6 100644 --- a/frontend/eslint-suppressions.json +++ b/frontend/eslint-suppressions.json @@ -1,15 +1,4 @@ { - "src/app/(public)/page.tsx": { - "@next/next/no-html-link-for-pages": { - "count": 1 - }, - "prefer-const": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/CustomSelect.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -165,4 +154,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/frontend/public/journal-ai-homework.png b/frontend/public/journal-ai-homework.png new file mode 100644 index 00000000..a4cc939a Binary files /dev/null and b/frontend/public/journal-ai-homework.png differ diff --git a/frontend/public/journal-founding.png b/frontend/public/journal-founding.png new file mode 100644 index 00000000..de503017 Binary files /dev/null and b/frontend/public/journal-founding.png differ diff --git a/frontend/public/kofi-symbol.png b/frontend/public/kofi-symbol.png new file mode 100644 index 00000000..98eb3ec9 Binary files /dev/null and b/frontend/public/kofi-symbol.png differ diff --git a/frontend/src/app/(public)/about/page.tsx b/frontend/src/app/(public)/about/page.tsx index 5bd299cf..38d882b9 100644 --- a/frontend/src/app/(public)/about/page.tsx +++ b/frontend/src/app/(public)/about/page.tsx @@ -1,319 +1,108 @@ -import type { Metadata } from "next"; -import Link from "next/link"; +import type { Metadata } from 'next'; +import { CompanionShell } from '@/components/companion/CompanionShell'; +import { ABOUT_AWARDS, ABOUT_DIFFERENTIATORS } from '@/lib/landing/companionContent'; + +/** + * About Sapling. + * + * Ported from `About Sapling.dc.html`. This page predates the design import + * and the import was built from it — its script header reads "copy taken + * verbatim from frontend/src/app/(public)/about/page.tsx" — so the prose here + * is unchanged. What the port replaces is the chrome: the page used to carry + * its own 52px bar (maxWidth 1280, hard border-bottom, a lone "Back to home" + * link) and its own footer, neither of which matched any other public page. + * + * Unlike its siblings this page has no eyebrow above the h1; the source goes + * straight to the title. + */ export const metadata: Metadata = { - title: "About", + title: 'About', description: - "The story behind Sapling: a student-built AI study partner from Boston University, recognized for reimagining how students learn through conversation and a living knowledge graph.", - alternates: { canonical: "/about" }, + 'The story behind Sapling: a student-built AI study partner from Boston University, recognized for reimagining how students learn through conversation and a living knowledge graph.', + alternates: { canonical: '/about' }, }; -const FOOTER_LINKS = [ - { label: "Home", href: "/" }, - { label: "About", href: "/about" }, - { label: "Careers", href: "/careers" }, - { label: "Terms of Service", href: "/terms" }, - { label: "Privacy Policy", href: "/privacy" }, -]; - -const differentiators = [ - "Your knowledge graph is yours. It updates in real time based on your actual performance, not just what you've clicked through.", - "Three distinct teaching modes mean you're never locked into one way of learning.", - "Study rooms let you learn alongside classmates and see how your mastery compares, anonymously and collaboratively.", - "Everything from syllabus tracking to exam study guides is powered by Gemini, so the busywork of getting organized is handled for you.", -]; +const MONO = "'JetBrains Mono',monospace"; +const SERIF = "'Spectral',Georgia,serif"; +const DISPLAY = "'Playfair Display',Georgia,serif"; -const awards = [ - { - title: "Best AI Tutor in Education", - org: "Boston University Civic Hacks 2026 · BU Spark! & Wheelock College of Education", - body: "Recognized among competing teams at BU's annual civic hackathon for building the most impactful AI-driven learning experience. Sapling was awarded for its approach to personalized, student-centered tutoring, bridging the gap between artificial intelligence and meaningful education.", - }, - { - title: "Code & Tell Winner", - org: "BU Spark!", - body: "Selected by BU Spark! as a standout project at their Code & Tell showcase, where student builders present real-world applications to faculty, mentors, and industry judges. Sapling was chosen for its technical depth and its vision for the future of how students learn.", - }, -]; +/** Body copy shares one type ramp; only the stagger delay changes. */ +const PROSE: React.CSSProperties = { + margin: 0, fontFamily: SERIF, fontWeight: 400, fontSize: 16, + lineHeight: 1.6, color: '#3f3b31', +}; export default function AboutPage() { return ( -
-
-
- - Sapling - - Sapling - - - - ← Back to home - -
-
- -
-

+ +
+

About Sapling

-
-

- Sapling{" "} - is an AI-powered study companion built by students, for students. We believe that - learning shouldn't be passive. It should adapt to you, challenge you, and show you - exactly where you stand. +

+

+ Sapling is an + AI-powered study companion built by students, for students. We believe that learning + shouldn’t be passive. It should adapt to you, challenge you, and show you exactly + where you stand.

-

+

At its core, Sapling maps your understanding as a live knowledge graph that grows with every session, quiz, and document you interact with. Paired with an AI tutor that can - reason with you Socratically, explain concepts directly, or flip the table and have - you teach back, Sapling meets you wherever you are in your learning journey. + reason with you Socratically, explain concepts directly, or flip the table and have you + teach back, Sapling meets you wherever you are in your learning journey.

-

+

Sapling was born out of a hackathon and built by a team of four students who were - frustrated with static study tools that don't actually know what you know. We wanted - something that feels less like a flashcard app and more like a study partner who's - always prepared. + frustrated with static study tools that don’t actually know what you know. We + wanted something that feels less like a flashcard app and more like a study partner + who’s always prepared.

-
-

+

+

What makes Sapling different:

-
    - {differentiators.map((item, i) => ( -
  • - - {item} +
      + {ABOUT_DIFFERENTIATORS.map((d) => ( +
    • + + {d}
    • ))}
-

- Sapling is actively developed and we're always building. If something's broken or - you have an idea, there's a feedback button in the navbar and we actually read those. +

+ Sapling is actively developed and we’re always building. If something’s + broken or you have an idea, there’s a feedback button in the navbar and we + actually read those.

-

+

Recognition

-
- {awards.map((award, i) => ( -
-

- {award.title} -

-

- {award.org} -

-

- {award.body} -

+
+ {ABOUT_AWARDS.map((a) => ( +
+

{a.title}

+

{a.org}

+

{a.body}

))}
-
- Built by Andres Lopez, Jack He, Luke Cooper, and Jose Gael Cruz-Lopez © 2026 +
+ Built by Andres Lopez, Jack He, Luke Cooper, and Jose Gael Cruz-Lopez © 2026
- -
-
-
- Sapling - Sapling · © 2026 -
-
- {FOOTER_LINKS.map(({ label, href }) => ( - - {label} - - ))} -
-
-
-

- © 2026 Andres Lopez, Jack He, Luke Cooper, and Jose Gael Cruz-Lopez. All Rights Reserved. -

-
-
-
+ ); } diff --git a/frontend/src/app/(public)/faq/page.tsx b/frontend/src/app/(public)/faq/page.tsx new file mode 100644 index 00000000..2fb1802b --- /dev/null +++ b/frontend/src/app/(public)/faq/page.tsx @@ -0,0 +1,119 @@ +'use client'; + +/** + * FAQ. + * + * Ported from `FAQ.dc.html`. Three labelled groups of questions over a + * two-button call-out. + * + * Distinct from the landing page's FAQ section, which is one flat list. Only + * one question is open at a time and the index spans all three groups, so the + * counter is resolved up front — the source increments it inside its render + * loop, which in React mutates during render. + */ + +import { useState } from 'react'; +import Link from 'next/link'; +import { CompanionShell } from '@/components/companion/CompanionShell'; +import { FAQ_GROUPS } from '@/lib/landing/companionContent'; + +const MONO = "'JetBrains Mono',monospace"; +const SERIF = "'Spectral',Georgia,serif"; +const DISPLAY = "'Playfair Display',Georgia,serif"; + +/** Groups paired with a running index across ALL groups, computed once. */ +const INDEXED = (() => { + let n = -1; + return FAQ_GROUPS.map((g) => ({ + label: g.label, + items: g.items.map((item) => ({ ...item, i: (n += 1) })), + })); +})(); + +export default function FaqPage() { + const [open, setOpen] = useState(0); + + return ( + +
+ + Straight answers + +

+ Questions we get +

+

+ The honest version, including the ones that are uncomfortable for us. If your question is + not here, it is worth asking us directly. +

+ +
+ {INDEXED.map((g) => ( +
+ + {g.label} + + {g.items.map((item) => { + const isOpen = open === item.i; + return ( +
+ +
+

+ {item.a} +

+
+
+ ); + })} +
+ ))} +
+ +
+
+ + Still curious + +

+ Definitions for the terms above live in the Wiki. Everything else, ask us in the beta + and one of the four of us will answer. +

+
+
+ + Read the Wiki + + + Join the beta + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/gallery/page.tsx b/frontend/src/app/(public)/gallery/page.tsx new file mode 100644 index 00000000..9972a50a --- /dev/null +++ b/frontend/src/app/(public)/gallery/page.tsx @@ -0,0 +1,114 @@ +'use client'; + +/** + * Gallery. + * + * Ported from `Gallery.dc.html`. A filterable grid of `
` tiles, each + * a 16/10 frame with its route badged over the top-left corner and a caption + * pairing the screen's title with its group. + * + * Tiles stagger in: `animation-delay` steps 50ms per tile and caps at 400ms, + * so a filter that returns twelve results still settles quickly. + * + * The frames are empty. The source fills them with its `image-slot` drop + * zone ("Drop a screenshot of …"), which is an authoring affordance rather + * than page content, and the import ships no screenshots. The route badge + * stays, so each tile still says what it is. + */ + +import { useState } from 'react'; +import Link from 'next/link'; +import { CompanionShell } from '@/components/companion/CompanionShell'; +import { GALLERY_FILTERS, GALLERY_SHOTS } from '@/lib/landing/companionContent'; + +const MONO = "'JetBrains Mono',monospace"; +const SERIF = "'Spectral',Georgia,serif"; +const DISPLAY = "'Playfair Display',Georgia,serif"; + +export default function GalleryPage() { + const [filter, setFilter] = useState('all'); + const shots = GALLERY_SHOTS.filter((s) => filter === 'all' || s.cat === filter); + + return ( + +
+ + Inside the product + +

+ Gallery +

+

+ Every screen in Sapling, as it actually looks. One course, one semester, and the same + graph behind all of it. +

+ +
+ {GALLERY_FILTERS.map((f) => { + const on = f.key === filter; + return ( + + ); + })} +
+ +
+ {shots.map((s, i) => ( +
+
+ + {s.route} + +
+
+ + {s.title} + {s.group} + + {s.body} +
+
+ ))} +
+ +
+
+ + Rather try it than look at it + +

+ Every screen here is playable on the home page. Open any tool in the toolkit and it + runs a real scenario. +

+
+ + Open the toolkit + +
+
+
+ ); +} diff --git a/frontend/src/app/(public)/news/page.tsx b/frontend/src/app/(public)/news/page.tsx new file mode 100644 index 00000000..0f3d0e17 --- /dev/null +++ b/frontend/src/app/(public)/news/page.tsx @@ -0,0 +1,220 @@ +'use client'; + +/** + * News. + * + * Ported from `News.dc.html`. A responsive card grid over a combined + * search-and-filter bar, closing on a Journal call-out. + * + * The filter is a real listbox rather than a row of pills — the source packs + * the query field, a clear button and the category menu into one rounded + * control, and the menu marks its active option with a check. + * + * Posts without artwork show a plain tinted panel. The source fills those + * with its `image-slot` authoring component (a drag-to-fill drop zone), which + * has no place in the shipped page; the import carries images for two of the + * six posts. + */ + +import { useEffect, useRef, useState } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { CompanionShell } from '@/components/companion/CompanionShell'; +import { NEWS_FILTERS, NEWS_POSTS } from '@/lib/landing/companionContent'; + +const MONO = "'JetBrains Mono',monospace"; +const SERIF = "'Spectral',Georgia,serif"; +const DISPLAY = "'Playfair Display',Georgia,serif"; + +/** The two posts the import ships artwork for. */ +const ART: Record = { + 'assets/journal-founding.png': '/journal-founding.png', + 'assets/journal-ai-homework.png': '/journal-ai-homework.png', +}; + +export default function NewsPage() { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + // close the category menu on an outside click, like the source's away handler + useEffect(() => { + const away = (e: PointerEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false); + }; + document.addEventListener('pointerdown', away); + return () => document.removeEventListener('pointerdown', away); + }, []); + + const q = query.trim().toLowerCase(); + const posts = NEWS_POSTS.filter((p) => { + if (filter !== 'all' && p.cat !== filter) return false; + if (!q) return true; + return `${p.title} ${p.excerpt} ${p.tag}`.toLowerCase().includes(q); + }); + + const activeLabel = NEWS_FILTERS.find((f) => f.key === filter)?.label ?? 'All articles'; + + return ( + +
+
+
+ + Notes from the build + +

+ News +

+
+ + Subscribe → + +
+ +

+ Releases, decisions, and what we are learning about learning. One letter a month while we + build. +

+ + {/* one control: query, clear, divider, category listbox */} +
+ + + + setQuery(e.target.value)} + placeholder="Search posts" + aria-label="Search posts" + style={{ flex: 1, minWidth: 0, border: 'none', outline: 'none', background: 'transparent', fontFamily: "'DM Sans',sans-serif", fontSize: 14, color: '#1a1814' }} + /> + {query && ( + + )} +
+ + {posts.length === 0 && ( +
+ + Nothing matches “{query}” + + + Try a different word, or clear the filters. + + +
+ )} + +
+ {posts.map((p) => { + const art = ART[p.src]; + return ( + +
+ {art && } +
+
+ + {p.date} + {p.tag} + +

{p.title}

+

{p.excerpt}

+ + Read article → + {p.time} + +
+ + ); + })} +
+ +
+
+ + The Sapling Journal + +

+ One letter a month, written by the four of us. First issue lands with the beta. +

+
+ + Get the Journal + +
+
+
+ ); +} diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index dc89e83f..2ae9ba6a 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -1,877 +1,171 @@ 'use client'; -import { useEffect, useState, useRef, useCallback } from 'react'; -import dynamic from 'next/dynamic'; -import { useRouter } from 'next/navigation'; -import { useUser } from '@/context/UserContext'; -import { useScrollLock } from '@/lib/useScrollLock'; -import { Users, PenSquare } from 'lucide-react'; +/** + * The Sapling landing page. + * + * Ported from `Sapling Landing v5.dc.html`. This is the page at `/` — it + * replaced the previous marketing landing outright rather than sitting + * beside it. + * + * Sections land incrementally; the engine drives whatever is mounted. + */ + +import { useState } from 'react'; import SignInModal from '@/components/marketing/SignInModal'; -import { HeroCard } from '@/components/marketing/HeroCard'; -import FeatureBand from '@/components/marketing/FeatureBand'; -import SurfaceBento from '@/components/marketing/SurfaceBento'; -import { FEATURE_BANDS } from '@/components/marketing/featureBands'; -import { BRAND_FOREST } from '@/lib/brand'; -import { Button } from "@/components/ui"; -import { IS_TEST_MODE, random, now } from '@/lib/testMode'; - -const KnowledgeGraphDemo = dynamic( - () => import('@/components/marketing/graph/KnowledgeGraphDemo'), - { - // Placeholder height approximates the section's resolved height so nothing - // below shifts while the chunk loads. MEASURED, not guessed: the section - // resolves to 990px at every desktop width once it wears its product - // chrome (#344 step 3) — the `80vh` this carried was 27–40% short of that - // even before, and a viewport-relative value cannot track a section whose - // height is set by a fixed-width inspector rail. - loading: () =>
, - }, -); - -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000'; - -const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_"; - -// Hand-tuned atmospheric orb palettes, hardcoded because they feed canvas -// fillStyle where CSS var() doesn't resolve. #3e6f8a mirrors --info in -// globals.css — the muted blue chosen to de-neon the old #3B82F6 (#106). -const CLUSTER_COLORS = ['#9CA3AF', '#D97706', '#3e6f8a', '#8A63D2', '#14B8A6', '#EF4444']; -const CLUSTER_SEEDS_BG = [10.0, 11.3, 12.6, 13.9, 15.2, 16.5]; -const CLUSTER_INIT_POS = [ - { ox: -222, oy: -29, oz: 15 }, - { ox: -161, oy: -59, oz: -20 }, - { ox: -81, oy: -42, oz: 30 }, - { ox: -67, oy: 17, oz: 0 }, - { ox: -168, oy: 59, oz: 20 }, - { ox: -229, oy: 8, oz: -30 }, -]; - -/* The globals.css `prefers-reduced-motion` block only neutralizes CSS - animations and transitions. The hero's canvas and card RAF loops are JS and - have to opt out themselves, or a reduced-motion visitor keeps paying for a - 60fps render they asked not to see. */ -const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; +import { ActGraph, RiseBand } from '@/components/landing-v5/ActGraph'; +import { ActIngest } from '@/components/landing-v5/ActIngest'; +import { BetaModal } from '@/components/landing-v5/BetaModal'; +import { ActTutor } from '@/components/landing-v5/ActTutor'; +import { FinalCta, SectionNav, SiteFooter } from '@/components/landing-v5/Closing'; +import { Faq } from '@/components/landing-v5/Faq'; +import { FeatureLab } from '@/components/landing-v5/FeatureLab'; +import { Journal } from '@/components/landing-v5/Journal'; +import { Gallery } from '@/components/landing-v5/Gallery'; +import { Hero } from '@/components/landing-v5/Hero'; +import { IntroOverlay } from '@/components/landing-v5/IntroOverlay'; +import { Navbar } from '@/components/landing-v5/Navbar'; +import { NAV_DARK, NAV_LIGHT, useNavDark } from '@/components/landing-v5/navTheme'; +import { useLanding } from '@/components/landing/useLanding'; export default function LandingPage() { - const router = useRouter(); - const { userReady, isAuthenticated } = useUser(); - - const [heroMounted, setHeroMounted] = useState(false); - const [heroText1, setHeroText1] = useState(''); - const [heroText2, setHeroText2] = useState(''); + const { + rootRef, ambientCanvasRef, navRef, heroCanvasRef, glCanvasRef, heroContentRef, + actCanvasRef, cinemaRef, ingestSceneRef, ingestStageRef, carouselRef, trackARef, trackBRef, + state, set, actions, + } = useLanding({ loadCounter: true }); + + // Carried over from the page this replaced: the nav's Sign In still opens + // the real OAuth modal rather than scrolling somewhere. const [signInOpen, setSignInOpen] = useState(false); - const [signInError, setSignInError] = useState(null); - const [betaModalOpen, setBetaModalOpen] = useState(false); - const [betaModalClosing, setBetaModalClosing] = useState(false); - const [betaEmail, setBetaEmail] = useState(''); - const [betaEmailError, setBetaEmailError] = useState(''); - const [betaSubmitted, setBetaSubmitted] = useState(false); - const [betaEverSubmitted, setBetaEverSubmitted] = useState(false); - const [betaSubmitting, setBetaSubmitting] = useState(false); - const closeModal = useCallback(() => { - setBetaModalClosing(true); - setTimeout(() => { - setBetaModalOpen(false); - setBetaModalClosing(false); - setBetaSubmitted(false); - setBetaEmail(''); - setBetaEmailError(''); - }, 200); - }, []); - const canvasRef = useRef(null); - const heroContentRef = useRef(null); - const floatingCardsRef = useRef(null); - const navRef = useRef(null); - const ambientGlowRef = useRef(null); - const parallaxYRef = useRef(0); - const mouseRef = useRef({ x: 0, y: 0 }); - - useEffect(() => { - if (betaSubmitted) { - const t = setTimeout(() => closeModal(), 3200); - return () => clearTimeout(t); - } - }, [betaSubmitted, closeModal]); - // Pre-auth there is no app shell, so is the real scroll container and - // useScrollLock resolves to it. - useScrollLock(betaModalOpen); - - // Always start at the top of the landing page so the intro sequence plays - // in order. Without this, browser scroll restoration on reload / hot - // reload can drop the user mid-section past the hero. - useEffect(() => { - if (typeof window !== 'undefined' && 'scrollRestoration' in window.history) { - window.history.scrollRestoration = 'manual'; - } - window.scrollTo({ top: 0, behavior: 'instant' }); - }, []); - - // If we landed here from an auth callback error or middleware redirect, - // surface the message in the sign-in modal and clean the param from the URL. - useEffect(() => { - if (typeof window === 'undefined') return; - const params = new URLSearchParams(window.location.search); - const err = params.get('error'); - if (!err) return; - setSignInError(err); - setSignInOpen(true); - params.delete('error'); - const qs = params.toString(); - const next = window.location.pathname + (qs ? `?${qs}` : ''); - window.history.replaceState({}, '', next); - }, []); - - const scrambleText = useCallback((setter: (v: string) => void, final: string, duration: number) => { - const start = Date.now(); - const interval = setInterval(() => { - const progress = (Date.now() - start) / duration; - if (progress >= 1) { setter(final); clearInterval(interval); return; } - setter(final.split('').map((ch, idx) => { - if (ch === ' ' || ch === '\n') return ch; - if (progress > idx / final.length) return ch; - return SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)]; - }).join('')); - }, 30); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - const timeout = setTimeout(() => { - setHeroMounted(true); - scrambleText(setHeroText1, 'Sapling', 1000); - setTimeout(() => scrambleText(setHeroText2, 'Grow Your Knowledge', 1200), 200); - }, 300); - return () => clearTimeout(timeout); - }, [scrambleText]); - - // 3D Canvas graph - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d', { alpha: true }); - if (!ctx) return; - - let width = 0, height = 0; - let rotAngle = 0; - let animId = 0; - - const reduceMotion = window.matchMedia(REDUCED_MOTION_QUERY); - // Test mode parks the loop on its single deterministic frame, same - // as prefers-reduced-motion. - let animating = !IS_TEST_MODE && !reduceMotion.matches; - - // #3e6f8a mirrors --info (globals.css); literal because canvas can't resolve var(). - const palette = [ - { c: '#8A63D2', w: 0.24 }, { c: '#3e6f8a', w: 0.24 }, - { c: '#D97706', w: 0.20 }, { c: '#14B8A6', w: 0.15 }, - { c: '#9CA3AF', w: 0.10 }, { c: '#D1D5DB', w: 0.07 }, - ]; - function randColor() { - let r = random(), s = 0; - for (const p of palette) { s += p.w; if (r <= s) return p.c; } - return palette[0].c; - } - - const clusters = [ - { x: -600, y: -250, z: 80 }, { x: -350, y: -100, z: -120 }, - { x: -100, y: -300, z: 200 }, { x: 150, y: -150, z: -80 }, - { x: 400, y: -250, z: 150 }, { x: 600, y: -100, z: -50 }, - { x: -500, y: 100, z: -150 }, { x: -200, y: 200, z: 100 }, - { x: 50, y: 150, z: -200 }, { x: 300, y: 250, z: 120 }, - { x: 550, y: 150, z: -100 }, { x: -400, y: 350, z: 60 }, - { x: 0, y: 0, z: 0 }, { x: 200, y: -50, z: -150 }, - ]; - const spread = 280; - const bgNodes = Array.from({ length: 220 }, () => { - const cl = clusters[Math.floor(random() * clusters.length)]; - return { - ox: cl.x + (random() - 0.5) * spread, - oy: cl.y + (random() - 0.5) * spread, - oz: cl.z + (random() - 0.5) * spread, - color: randColor(), - radius: 1 + random() * 4, - seed: random() * 100, - clusterIndex: undefined as number | undefined, - }; - }); - const clusterNodes = CLUSTER_INIT_POS.map((pos, i) => ({ - ox: pos.ox, oy: pos.oy, oz: pos.oz, - color: CLUSTER_COLORS[i], - radius: 2.5 + random() * 1.5, - seed: CLUSTER_SEEDS_BG[i], - clusterIndex: i as number | undefined, - })); - const nodes = [...bgNodes, ...clusterNodes]; - - function resize() { - width = window.innerWidth; - height = window.innerHeight; - canvas!.width = width * devicePixelRatio; - canvas!.height = height * devicePixelRatio; - ctx!.scale(devicePixelRatio, devicePixelRatio); - // Resizing clears the backing store. With the loop parked there's no - // next frame to repaint it, so repaint the static one here. - if (!animating) draw(); - } - window.addEventListener('resize', resize); - resize(); - - function draw() { - if (!ctx) return; - ctx.clearRect(0, 0, width, height); - rotAngle += 0.0008; - const fl = 1000, cx = width / 2, cy = height / 2, t = now() * 0.001; - const mx = mouseRef.current.x, my = mouseRef.current.y; - - const proj = nodes.map(n => { - const ny = n.oy + Math.sin(t * 0.4 + n.seed) * 15; - let x = n.ox * Math.cos(rotAngle) - n.oz * Math.sin(rotAngle); - let z = n.oz * Math.cos(rotAngle) + n.ox * Math.sin(rotAngle); - x -= mx * (z + fl) * 0.02; - const y2 = ny - my * (z + fl) * 0.02; - const sc = fl / (fl + z); - return { x: x * sc + cx, y: y2 * sc + cy - parallaxYRef.current, z, sc, n }; - }).sort((a, b) => b.z - a.z); - - ctx.globalCompositeOperation = 'source-over'; - ctx.lineWidth = 0.5; - // The pair walk itself is unavoidable (links depend on projected - // positions, which change every frame), but almost every pair fails the - // distance test — so make failing cheap: hoist the per-node threshold - // out of the inner loop, reject on |dx|/|dy| before multiplying, and - // compare squared distances so the sqrt only runs for pairs that - // actually draw a link. - for (let i = 0; i < proj.length; i++) { - const p1 = proj[i]; - const maxD = 70 * p1.sc; - const maxD2 = maxD * maxD; - const aScale = 0.15 * Math.min(1, p1.sc); - for (let j = i + 1; j < proj.length; j++) { - const p2 = proj[j]; - const dx = p1.x - p2.x; - if (dx > maxD || dx < -maxD) continue; - const dy = p1.y - p2.y; - if (dy > maxD || dy < -maxD) continue; - const d2 = dx * dx + dy * dy; - if (d2 < maxD2) { - const a = (1 - Math.sqrt(d2) / maxD) * aScale; - if (a > 0.002) { - ctx.strokeStyle = `rgba(156,163,175,${a})`; - ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); - } - } - } - } - - proj.forEach(p => { - if (p.z > -fl) { - const breathe = 0.92 + 0.08 * Math.sin(t * 0.6 + p.n.seed); - const fogA = p.z > 500 ? Math.max(0, 1 - (p.z - 500) / 500) : 1; - const r = p.n.radius * p.sc * breathe; - if (r > 0.1) { - ctx.globalAlpha = fogA; - ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fillStyle = p.n.color; ctx.fill(); - } - } - }); - ctx.globalAlpha = 1; - - if (animating) animId = requestAnimationFrame(draw); - } - - // Toggling the OS preference mid-session either parks the loop on a - // static frame or restarts it; `draw` self-schedules only when animating. - const onMotionPrefChange = () => { - const next = !IS_TEST_MODE && !reduceMotion.matches; - if (next === animating) return; - animating = next; - cancelAnimationFrame(animId); - draw(); - }; - reduceMotion.addEventListener('change', onMotionPrefChange); - - draw(); - return () => { - window.removeEventListener('resize', resize); - reduceMotion.removeEventListener('change', onMotionPrefChange); - cancelAnimationFrame(animId); - }; - }, []); - - // Mouse + scroll - useEffect(() => { - const onMouse = (e: MouseEvent) => { - mouseRef.current = { x: (e.clientX / window.innerWidth - 0.5) * 2, y: (e.clientY / window.innerHeight - 0.5) * 2 }; - }; - let lastSy = window.scrollY; - - const updateNavChrome = (sy: number) => { - const nav = navRef.current; - if (!nav) return; - const scrollingDown = sy > lastSy; - const pastHero = sy > window.innerHeight * 0.5; - nav.style.transform = scrollingDown && pastHero ? 'translateY(-100%)' : 'translateY(0)'; - nav.classList.remove('shadow-sm'); - nav.style.background = 'transparent'; - nav.style.backdropFilter = 'none'; - nav.style.setProperty('-webkit-backdrop-filter', 'none'); - nav.style.borderBottomColor = 'transparent'; - }; - - const updateAmbientGlow = (sy: number) => { - const glow = ambientGlowRef.current; - if (!glow) return; - const progress = Math.min(1, Math.max(0, (sy - 20) / 260)); - const eased = progress * progress; - glow.style.opacity = eased.toString(); - }; + // Added by request. The design scrolls both beta CTAs down to the + // newsletter section instead; they open this dialog now. + const [betaOpen, setBetaOpen] = useState(false); - const applyScroll = () => { - const sy = window.scrollY; - if (heroContentRef.current && sy < window.innerHeight) { - heroContentRef.current.style.transform = `translateY(${sy * -0.3}px)`; - parallaxYRef.current = sy * 0.1; - } - updateNavChrome(sy); - updateAmbientGlow(sy); - lastSy = sy; - }; - - // Scroll fires far more often than the compositor paints, and each call - // writes inline styles on three elements. Coalesce to one write per frame. - let queued = 0; - const onScroll = () => { - if (queued) return; - queued = requestAnimationFrame(() => { queued = 0; applyScroll(); }); - }; - - document.addEventListener('mousemove', onMouse, { passive: true }); - window.addEventListener('scroll', onScroll, { passive: true }); - applyScroll(); - return () => { - document.removeEventListener('mousemove', onMouse); - window.removeEventListener('scroll', onScroll); - cancelAnimationFrame(queued); - }; - }, []); - - // Floating cards parallax - useEffect(() => { - // The cards are static markup, so resolve the NodeList and parse their - // dataset floats once instead of re-doing both 60 times a second. - const cards = Array.from( - floatingCardsRef.current?.querySelectorAll('.floating-card') ?? [], - ).map(el => ({ - el, - baseRot: parseFloat(el.dataset.baseRot || '0'), - dur: parseFloat(el.dataset.floatDur || '5000'), - delay: parseFloat(el.dataset.floatDelay || '0'), - })); - if (cards.length === 0) return; - - const reduceMotion = window.matchMedia(REDUCED_MOTION_QUERY); - let animId = 0; - let animating = !IS_TEST_MODE && !reduceMotion.matches; - - function paint() { - const t = Date.now(); - const mx = mouseRef.current.x, my = mouseRef.current.y; - // Reduced motion keeps the cards' resting tilt but drops the drift, - // the mouse tilt and the scroll parallax. - const rx = animating ? -my * 5 : 0; - const ry = animating ? mx * 5 : 0; - const par = animating ? window.scrollY * -0.3 : 0; - for (const { el, baseRot, dur, delay } of cards) { - const floatY = animating ? Math.sin((t - delay) / dur * Math.PI * 2) * -8 : 0; - el.style.transform = `perspective(1000px) translateY(${floatY + par}px) rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${baseRot}deg)`; - } - if (animating) animId = requestAnimationFrame(paint); - } - - const onMotionPrefChange = () => { - const next = !IS_TEST_MODE && !reduceMotion.matches; - if (next === animating) return; - animating = next; - cancelAnimationFrame(animId); - paint(); - }; - reduceMotion.addEventListener('change', onMotionPrefChange); - - paint(); - return () => { - reduceMotion.removeEventListener('change', onMotionPrefChange); - cancelAnimationFrame(animId); - }; - }, []); - - // Intersection observer for fade-ups - useEffect(() => { - const obs = new IntersectionObserver(entries => { - entries.forEach(entry => { - if (entry.isIntersecting) { - entry.target.classList.remove('opacity-0', 'translate-y-[30px]'); - obs.unobserve(entry.target); - } - }); - }, { threshold: 0.2 }); - document.querySelectorAll('.landing-fade-up').forEach(el => { - el.classList.add('opacity-0', 'translate-y-[30px]', 'transition-all', 'duration-700', 'ease-out'); - obs.observe(el); - }); - return () => obs.disconnect(); - }, []); - - // ── Onboarding entry ────────────────────────────────────────────── - // The signup flow lives at /onboarding (screens/Onboarding). Unauthenticated - // visitors sign in first; SignInModal routes them onward based on - // onboarding_completed. - function startOnboarding() { - if (!userReady) return; - if (!isAuthenticated) { - setSignInError(null); - setSignInOpen(true); - return; - } - router.push('/onboarding'); - } + // The source builds this table then pins it to light with `wantDark = false`. + // Driven for real here — see navTheme.ts. + const navTheme = useNavDark() ? NAV_DARK : NAV_LIGHT; return ( -
-
- - {/* ═══ Initial load intro overlay ═══ */} -
-
-
-
-
-
-
-
-
-
- Sapling -
-
- Growing your knowledge -
-
-
-
- - {/* ═══ Navbar ═══ */} - + /> - {/* ═══ Hero Section ═══ */} -
-
-
-
-
- + - {/* Floating Glass Accent Cards */} -
-
- {/* - Canonical knowledge-status tokens (globals.css:80-89), not literals - (#344 visual 1b). These four swatches and the knowledge-graph - section's node colours are one viewport apart on the same page, so - they have to be the same palette — and it has to be the palette the - signed-in product actually uses. Colours only; nothing else in the - hero moves. - */} -
-
Mastered
-
Learning
-
Struggling
-
Unexplored
-
-
+ set.setNavMenuOpen(!state.navMenuOpen)} + onCloseMenu={() => set.setNavMenuOpen(false)} + onLogoClick={actions.scrollTop} + onSignIn={() => setSignInOpen(true)} + onGetStarted={() => actions.scrollToId('cta')} + /> -
-
- - Quick Quiz -
-
- - Study Room -
-
-
+ setSignInOpen(false)} /> + + setBetaOpen(false)} + /> - {/* Hero Content */} -
-

- {heroText1 || '\u00A0'} -

+ setBetaOpen(true)} + onSeeHow={() => actions.scrollToId('gallery')} + /> -

- {heroText2 || '\u00A0'} -

+ {/* ═══ Descent band ═══ */} + + { actions.exitExplore(); setTimeout(() => actions.openGal(0, null), 260); }} + onLearn={() => { actions.exitExplore(); setTimeout(() => actions.openGal(2, null), 260); }} + /> - {/* Scroll Indicator */} -
-
- SEE WHAT'S INSIDE -
-
+ -
- + - {/* ═══ Feature bands + bento ═══ - One arc — material in → practice → retention — with the bento of - built surfaces re-energising the middle. Band 3 is the closing - claim, so it (not a grid tile) hands off to the CTA; content and - side-alternation live in components/marketing/featureBands.tsx. */} - - - - + - {/* ═══ Final CTA ═══ */} -
- {/* Soft green wash in the section's upper body. - This used to start at full strength on the very top edge, because - it was blending DOWN out of HowItWorks' dark-green scroll tint. - That section is gone, so a tint that starts at 0.08 on the - boundary now fades in from flat paper and reads as a hard seam. - Peak it below the edge and start from transparent instead. */} -
-
-
-
-
-
-

- Ready to
Start Growing? -

-

Join students who learn smarter, not harder.

-
- -
-
-
+ - {/* ═══ Footer ═══ */} - -
+ - {/* ═══ Beta / Newsletter Panel ═══ */} - {betaModalOpen && betaSubmitted && ( -
- e.stopPropagation()} - > -

- You're on the tree. -

-

- See you in the inbox - The Team -

-
-
- )} - {betaModalOpen && !betaSubmitted && ( -
{ if (!betaSubmitting) closeModal(); }} - > - e.stopPropagation()} - role="dialog" - aria-modal="true" - aria-label="Beta access and newsletter signup" - > - {/* Close */} - + - {/* ── Left: brand + perks panel ── */} -
-
- Sapling - Sapling -
-
-
Early access
-

- Learn early.
Grow with us. -

-

- Sapling is being built alongside the students who'll use it most. Join early and help shape what it becomes. -

-
-
-
-

- Beta Tester Role -

-
-
- AK -
-
-
- Alex Kim - - Beta Tester - -
- @alexkim -
-
-

- The first mark on your profile. A permanent record of showing up early. -

-
-
+ setBetaOpen(true)} /> - {/* ── Column divider ── */} -
+ - {/* ── Right: newsletter form ── */} -
-
- ● Issue 001 dropping soon -
-

- Join the
- Newsletter -

-

- Hear fun stories from students like you. -

-
- {([ - { dot: BRAND_FOREST, title: 'New features, first.', body: 'Every study mode, knowledge tool, and capability before anyone else sees it.' }, - { dot: '#D97706', title: 'Real notes from the team.', body: "What we're figuring out as we build. Honest, occasional, and worth opening." }, - { dot: '#8A63D2', title: 'Your input shapes what we build.', body: 'Early polls, roadmap previews, and a direct line to the people building it.' }, - ] as Array<{ dot: string; title: string; body: string }>).map(({ dot, title, body }) => ( -
-
-
-
{title}
-
{body}
-
-
- ))} -
-
{ - e.preventDefault(); - const trimmed = betaEmail.trim(); - if (!trimmed) { setBetaEmailError('Enter a valid email (e.g. you@example.com)'); return; } - const [local, domain] = trimmed.split('@'); - if (!local || !domain || !domain.includes('.')) { - setBetaEmailError('Enter a valid email (e.g. you@example.com)'); - return; - } - setBetaEmailError(''); - setBetaSubmitting(true); - try { - await fetch(`${API_URL}/api/newsletter/subscribe`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: betaEmail.trim() }), - }); - } catch { - // fail silently — still show success - } - setBetaSubmitting(false); - setBetaSubmitted(true); - setBetaEverSubmitted(true); - }} - style={{ marginTop: 'auto', paddingTop: 28 }} - > -
- { setBetaEmail(e.target.value); setBetaEmailError(''); }} - style={{ - width: '100%', padding: '14px 16px', fontSize: 14, - background: 'rgba(255,255,255,0.6)', - border: '1.5px solid rgba(107,114,128,0.25)', - borderRadius: 10, - transition: 'border-color 0.15s, box-shadow 0.15s', - fontFamily: "var(--font-dm-sans), 'DM Sans', sans-serif", - color: '#1a1a1a', boxSizing: 'border-box', - }} - onFocus={e => { e.target.style.borderColor = 'var(--brand-forest)'; e.target.style.boxShadow = '0 0 0 3px rgba(27,108,66,0.12)'; }} - onBlur={e => { e.target.style.borderColor = 'rgba(107,114,128,0.25)'; e.target.style.boxShadow = 'none'; }} - /> - {betaEmailError && ( -

- {betaEmailError} -

- )} -
- -

- By joining the newsletter, you'll also be added to the beta waitlist. -

-
-
- -
- )} + set.setJumpOpen(!state.jumpOpen)} + onJump={actions.scrollToId} + onTop={actions.scrollTop} + /> - {/* ═══ Sign-in modal ═══ */} - { setSignInOpen(false); setSignInError(null); }} - errorCode={signInError} + actions.openGal(i, null)} />
); diff --git a/frontend/src/app/(public)/team/page.tsx b/frontend/src/app/(public)/team/page.tsx new file mode 100644 index 00000000..e90683fb --- /dev/null +++ b/frontend/src/app/(public)/team/page.tsx @@ -0,0 +1,91 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { CompanionShell } from '@/components/companion/CompanionShell'; +import { TEAM_MEMBERS, TEAM_WAYS } from '@/lib/landing/companionContent'; + +/** + * Meet the team. + * + * Ported from `Meet the Team.dc.html`. Square portrait tiles over a "How we + * work" list, closing on a byline and a beta link. The source's awards block + * is dropped here — /about already carries it, and one copy is enough. + * + * Portrait frames are empty: the source fills them with its `image-slot` + * drop zone ("Drop a photo of …"), an authoring affordance, and the import + * ships no photographs of the team. + */ + +export const metadata: Metadata = { + title: 'Meet the team', + description: + 'A small team out of Boston University who got tired of study tools that did not know what they were studying.', +}; + +const MONO = "'JetBrains Mono',monospace"; +const SERIF = "'Spectral',Georgia,serif"; +const DISPLAY = "'Playfair Display',Georgia,serif"; + +const EYEBROW: React.CSSProperties = { + display: 'block', fontFamily: MONO, fontSize: 10, letterSpacing: '0.14em', + textTransform: 'uppercase', color: '#2D8F5C', +}; + +export default function TeamPage() { + return ( + +
+ {/* two motes drifting at the page edges */} + + + + The people + +

+ Meet the team +

+

+ A small team out of Boston University who got tired of study tools that did not know what + they were studying. We build Sapling between problem sets, and we use it for our own + classes first. +

+ +
+ {TEAM_MEMBERS.map((m) => ( +
+
+
+ {m.name} + {m.role} + {m.body} +
+
+ ))} +
+ +
+ How we work +
+ {TEAM_WAYS.map((w) => ( +
+ + {w} +
+ ))} +
+
+ +
+ + Built by Andres Lopez, Jack He, Luke Cooper, and Jose Gael Cruz-Lopez © 2026 + + + Join the beta → + +
+
+ + ); +} diff --git a/frontend/src/app/(public)/wiki/page.tsx b/frontend/src/app/(public)/wiki/page.tsx new file mode 100644 index 00000000..9a88d63f --- /dev/null +++ b/frontend/src/app/(public)/wiki/page.tsx @@ -0,0 +1,197 @@ +import type { Metadata } from 'next'; +import { CompanionShell } from '@/components/companion/CompanionShell'; +import { + WIKI_DATA_FACTS, WIKI_GRAPH_TERMS, WIKI_LETTERS, WIKI_MODES, + WIKI_PIPELINE, WIKI_RATINGS, WIKI_TIERS, WIKI_TOC, +} from '@/lib/landing/companionContent'; + +/** + * Wiki. + * + * Ported from `Wiki.dc.html`. A sticky contents rail beside seven definition + * sections, each laid out for what it holds: two-column term/definition rows + * for the graph and tutor modes, a four-column row with a tier swatch for + * mastery, cards for the review intervals, a numbered list for ingestion, and + * chips for the grade bands. + */ + +export const metadata: Metadata = { + title: 'Wiki', + description: + 'Exact definitions for the terms and numbers Sapling puts on screen. Every value here is the one the product actually uses.', +}; + +const MONO = "'JetBrains Mono',monospace"; +const SERIF = "'Spectral',Georgia,serif"; +const DISPLAY = "'Playfair Display',Georgia,serif"; + +const H2: React.CSSProperties = { + margin: 0, fontFamily: DISPLAY, fontWeight: 500, fontSize: 26, + lineHeight: 1.2, letterSpacing: '-0.015em', scrollMarginTop: 84, +}; +const LEDE: React.CSSProperties = { + margin: '12px 0 0', fontFamily: SERIF, fontSize: 15, lineHeight: 1.6, + color: '#3f3b31', maxWidth: '64ch', +}; +/** Definition text, shared by every row style below. */ +const DEF: React.CSSProperties = { fontFamily: SERIF, fontSize: 14.5, lineHeight: 1.6, color: '#3f3b31' }; +/** The hairline that separates rows within a section. */ +const ROW_TOP = '1px solid rgba(42,39,31,0.08)'; + +/** `dot`/`tone` come from the source as CSS declaration strings. */ +function cssColor(decl: string): string { + return decl.replace(/^(background|color):/, '').replace(/;$/, '').trim(); +} + +export default function WikiPage() { + return ( + +
+ + Reference + +

+ Wiki +

+

+ Exact definitions for the terms and numbers Sapling puts on screen. Every value here is + the one the product actually uses. +

+ +
+ + +
+
+

Knowledge graph

+

+ One node per concept in a course, joined by an edge when learning one depends on the + other. Nodes are positioned by unit, so the shape of the graph is the shape of the + syllabus. +

+
+ {WIKI_GRAPH_TERMS.map((g) => ( +
+ {g.term} + {g.def} +
+ ))} +
+
+ +
+

Mastery tiers

+

+ Every node carries a mastery score from 0 to 1, drawn as a ring around it. The score + moves on demonstrated understanding, not time spent. +

+
+ {WIKI_TIERS.map((t) => ( +
+ + {t.name} + {t.range} + {t.meaning} +
+ ))} +
+
+ +
+

Spaced review

+

+ After each card you rate your recall, and that rating sets when the card comes back. + Rating a card also writes to the mastery score of the concept it tests. +

+
+ {WIKI_RATINGS.map((r) => ( +
+ + {r.label} + KEY {r.key} + + next in {r.due} +
+ ))} +
+
+ +
+

Tutor modes

+

+ Three ways to work the same concept. All three are grounded in documents you + uploaded, and none of them will hand over an answer. +

+
+ {WIKI_MODES.map((m) => ( +
+ {m.name} + {m.def} +
+ ))} +
+
+ +
+

Ingestion

+

+ What happens to a file after you drop it in. Each step is visible in the product, so + you can always see why a concept or a date exists. +

+
+ {WIKI_PIPELINE.map((p) => ( +
+ {p.num} + + {p.title} + {p.body} + +
+ ))} +
+
+ +
+

Grade scale

+

+ Category weights come from your syllabus, and every score rolls into one weighted + number. These are the letter bands it maps onto. +

+
+ {WIKI_LETTERS.map((l) => ( + + {l.letter} + {l.min} + + ))} +
+
+ +
+

Your data

+
+ {WIKI_DATA_FACTS.map((d) => ( +
+ + + + {d} +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 0c6bc7a2..4044eb61 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1871,3 +1871,241 @@ input[type="reset"]:disabled { border-radius: var(--r-full); background: var(--grade); } + +/* ══════════════════════════════════════════════════════════════════ + LANDING (design-component ports) — shared foundation + Ported from the `Sapling Landing *.dc.html` design components. + + Scoped to .landing-dc, worn by every ported landing route (/v4, /v5), + so these sit alongside the existing .landing-page surface above rather + than replacing it in-place. + + Fonts reuse the app's next/font variables: DM Sans, Spectral, Playfair + Display and JetBrains Mono are exactly the four families the source + pulls from Google Fonts, so no extra webfont request is needed. + + prefers-reduced-motion is deliberately NOT repeated here — the global + block near the top of this file already collapses animation and + transition durations to 0.01ms, a superset of the source's rule. + ══════════════════════════════════════════════════════════════════ */ + +.landing-dc { + /* Core palette, literal source values. + These are NOT the app's brand tokens and must not be consolidated into + them: #0C5638 / #0E9E5A are their own scale, distinct from + --brand-forest (#1B6C42) / --brand-forest-bright (#2D8F5C). */ + --ld-deep: #0C5638; + --ld-bright: #0E9E5A; + --ld-mid: #4FA574; + --ld-pale: #8FD9A8; + --ld-coral: #E27A63; + --ld-ink: #12201A; + --ld-muted: #61726A; + --ld-page: #f0f4f2; + --ld-stage: #081F14; + --ld-stage-deep: #061710; + + /* Mastery tiers — mirrored by XTIER in lib/landing/course.ts. + --ld-tier-unexplored is byte-identical to --state-neutral today but is + kept literal for the same reason --fallback-muted is: distinct concept, + so a future neutral-state change can't silently move a landing tier. */ + --ld-tier-mastered: #0E9E5A; + --ld-tier-learning: #4FA574; + --ld-tier-struggling: #E27A63; + --ld-tier-unexplored: #9a9a9a; + + /* The source uses Spectral for the wordmark + display serif and Playfair + as the secondary serif — the reverse of the app's --font-display / + --font-serif roles, so bind to the concrete families, not the roles. */ + --ld-font-sans: var(--font-dm-sans), 'DM Sans', system-ui, sans-serif; + --ld-font-display: var(--font-spectral), 'Spectral', georgia, serif; + --ld-font-serif: var(--font-playfair), 'Playfair Display', georgia, serif; + --ld-font-mono: var(--font-jetbrains), 'JetBrains Mono', ui-monospace, monospace; + + background: var(--ld-page); + color: var(--ld-ink); + font-family: var(--ld-font-sans); + overflow-x: clip; +} + +.landing-dc a { color: var(--ld-deep); text-decoration: none; } +.landing-dc a:hover { color: var(--ld-bright); } + +/* Three responsive rules carry the whole layout. */ +@media (max-width: 1023px) { + .landing-dc .floating-card { display: none !important; } + .landing-dc .drag-field { display: none !important; } +} +@media (max-width: 1180px) { + .landing-dc .nav-tabs { display: none !important; } + .landing-dc .nav-compact { display: flex !important; } +} + +.landing-dc ::-webkit-scrollbar { width: 6px; } +.landing-dc ::-webkit-scrollbar-track { background: var(--ld-page); } +.landing-dc ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; } +.landing-dc .gal-rail { scrollbar-width: none; } +.landing-dc .gal-rail::-webkit-scrollbar { display: none; } + +/* ── Intro lockup ────────────────────────────────────────────────── + A hand-tuned causal sequence, 2.4s, played once with `both`. The stem + passes each joint before that part grows; the leaves then settle under + their own weight. The per-keyframe animation-timing-function + declarations ARE the effect — collapsing them onto one shared curve + destroys it. Do not "simplify" these. + ────────────────────────────────────────────────────────────────── */ +@keyframes s1Stem { 0%,15% { stroke-dashoffset:27; } 34%,100% { stroke-dashoffset:0; } } +@keyframes s1LeafLo { + 0%,27% { transform:scale(0.05) rotate(9deg); opacity:0; animation-timing-function:cubic-bezier(0.16,0.9,0.3,1); } + 36% { transform:scale(1.03) rotate(5deg); opacity:1; animation-timing-function:cubic-bezier(0.5,0,0.85,0.3); } + 42% { transform:scale(1) rotate(-4.5deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } + 46% { transform:scale(1) rotate(-0.4deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } + 51%,100% { transform:scale(1) rotate(-2.4deg); opacity:1; } } +@keyframes s1LeafHi { + 0%,33% { transform:scale(0.05) rotate(-9deg); opacity:0; animation-timing-function:cubic-bezier(0.16,0.9,0.3,1); } + 42% { transform:scale(1.03) rotate(-5deg); opacity:1; animation-timing-function:cubic-bezier(0.5,0,0.85,0.3); } + 48% { transform:scale(1) rotate(5.4deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } + 52% { transform:scale(1) rotate(0.8deg); opacity:1; animation-timing-function:cubic-bezier(0.4,0,0.6,1); } + 57%,100% { transform:scale(1) rotate(3.2deg); opacity:1; } } +@keyframes s1Bud { 0%,29% { transform:scale(0); } 35%,100% { transform:scale(1); } } +@keyframes s1Word { 0%,2% { opacity:0; transform:translateY(8px); } 14%,100% { opacity:1; transform:translateY(0); } } +@keyframes s1Rule { 0%,60% { transform:scaleX(0); } 74%,100% { transform:scaleX(1); } } + +/* ── Ambient / hero ────────────────────────────────────────────────── */ +@keyframes saplingBlob { 0% { transform:translate(0,0) scale(1); } 33% { transform:translate(30px,-50px) scale(1.1); } 66% { transform:translate(-20px,20px) scale(0.9); } 100% { transform:translate(0,0) scale(1); } } +@keyframes introOrbit { to { transform:rotate(360deg); } } +@keyframes floatIndicator { 0%,100% { transform:translateY(0) translateX(-50%); } 50% { transform:translateY(6px) translateX(-50%); } } +@keyframes betaGlow { 0%,100% { box-shadow:0 0 0 0 rgba(14,158,90,0), 0 4px 20px rgba(14,158,90,0.15); } 50% { box-shadow:0 0 0 5px rgba(14,158,90,0.45), 0 4px 28px rgba(14,158,90,0.3); } } +@keyframes nodeFloatA { 0%,100% { transform:translate(0,0); } 33% { transform:translate(16px,-24px); } 66% { transform:translate(-12px,14px); } } +@keyframes nodeFloatB { 0%,100% { transform:translate(0,0); } 40% { transform:translate(-18px,18px); } 75% { transform:translate(10px,-12px); } } + +/* ── Gallery card miniatures ───────────────────────────────────────── */ +@keyframes popHold { 0% { opacity:0; transform:translateY(-10px) scale(0.94); } 6% { opacity:1; transform:translateY(0) scale(1); } 86% { opacity:1; } 96% { opacity:0; } 100% { opacity:0; } } +@keyframes chipSwapA { 0%,42% { opacity:1; transform:translateY(0); } 50%,92% { opacity:0; transform:translateY(-8px); } 100% { opacity:1; transform:translateY(0); } } +@keyframes chipSwapB { 0%,42% { opacity:0; transform:translateY(8px); } 50%,92% { opacity:1; transform:translateY(0); } 100% { opacity:0; transform:translateY(8px); } } +@keyframes flipLoop { 0%,38% { transform:rotateY(0deg); } 50%,88% { transform:rotateY(180deg); } 100% { transform:rotateY(360deg); } } +@keyframes lineReveal { 0% { clip-path:inset(0 100% 0 0); } 12% { clip-path:inset(0 -4px 0 0); } 90% { clip-path:inset(0 -4px 0 0); } 100% { clip-path:inset(0 100% 0 0); } } +@keyframes underSweep { 0%,8% { transform:scaleX(0); } 18% { transform:scaleX(1); } 88% { transform:scaleX(1); } 100% { transform:scaleX(0); } } +@keyframes barGrow { 0% { transform:scaleY(0.08); } 14% { transform:scaleY(1); } 86% { transform:scaleY(1); } 100% { transform:scaleY(0.08); } } +@keyframes coverRise { 0% { transform:translateY(46px) rotate(-1deg); opacity:0; } 16% { transform:translateY(0) rotate(-1deg); opacity:1; } 84% { transform:translateY(0) rotate(-1deg); opacity:1; } 100% { transform:translateY(46px) rotate(-1deg); opacity:0; } } +@keyframes ringDraw { 0% { stroke-dashoffset:100; } 30%,85% { stroke-dashoffset:8.8; } 100% { stroke-dashoffset:100; } } +@keyframes gaugeFloat { 0%,100% { transform:translateY(0); } 30% { transform:translateY(-52px); } 65% { transform:translateY(20px); } } +@keyframes answerTick { 0%,55% { box-shadow:0 0 0 0 rgba(14,158,90,0); } 62% { box-shadow:0 0 0 7px rgba(14,158,90,0.22); } 72%,100% { box-shadow:0 0 0 0 rgba(14,158,90,0); } } +@keyframes waveBar { 0%,100% { transform:scaleY(0.25); } 50% { transform:scaleY(1); } } +@keyframes gaugeMini { 0%,100% { transform:translate(-50%,0); } 30% { transform:translate(-50%,-32px); } 65% { transform:translate(-50%,12px); } } +@keyframes coverMini { 0% { transform:translateY(30px); opacity:0; } 16% { transform:translateY(0); opacity:1; } 84% { transform:translateY(0); opacity:1; } 100% { transform:translateY(30px); opacity:0; } } +@keyframes cursorMini { 0%,100% { transform:translate(0,0); } 30% { transform:translate(22px,-14px); } 70% { transform:translate(-16px,10px); } } +@keyframes cardFloat { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-9px); } } +@keyframes scanSweep { 0% { transform:translateY(-14px); opacity:0; } 8% { opacity:1; } 92% { opacity:1; } 100% { transform:translateY(212px); opacity:0; } } +@keyframes ocrBlink { 0%,100% { opacity:0.35; } 50% { opacity:1; } } +@keyframes panelFade { from { opacity:0; } to { opacity:1; } } +@keyframes fillX { 0% { transform:scaleX(0); } 18% { transform:scaleX(1); } 86% { transform:scaleX(1); } 100% { transform:scaleX(0); } } +@keyframes typingDot { 0%,60%,100% { opacity:0.25; transform:translateY(0); } 30% { opacity:1; transform:translateY(-3px); } } +@keyframes ringPop { 0% { opacity:0; r:0; } 12% { opacity:1; } 88% { opacity:1; } 100% { opacity:0; } } +@keyframes cursorDrift { 0%,100% { transform:translate(0,0); } 30% { transform:translate(38px,-22px); } 70% { transform:translate(-26px,16px); } } + +/* ── Hover states ────────────────────────────────────────────────── + The source expresses these with a `style-hover` attribute, which is a + design-canvas construct with no inline equivalent in React. Same values, + moved into real CSS. + ────────────────────────────────────────────────────────────────── */ +.landing-dc .ld-navlink:hover { color: var(--ld-ink); } +.landing-dc .ld-btn-solid:hover { filter: brightness(1.12); } +.landing-dc .ld-btn-ghost:hover { border-color: var(--ld-deep); color: var(--ld-deep); } +.landing-dc .ld-navmenu-item:hover { background: rgba(12,86,56,0.07); color: var(--ld-ink); } +.landing-dc .ld-kofi:hover { + color: var(--ld-ink); + border-color: #FF5E1A; /* Ko-fi brand orange — not part of the Sapling palette */ + background: #FFF3EC; +} + +/* ── Landing v5 additions ────────────────────────────────────────── + Ported from `Sapling Landing v5.dc.html`. The v5 hero is a ground-up + rebuild, not a v4 tweak: the wordmark is Playfair 600 in brand forest + (not Archivo 800 in ink), it arrives via a character scramble rather + than a wipe, and the DOM `.floating-card` rig is replaced by a WebGL + scene. Everything above this comment is shared with v4. + ────────────────────────────────────────────────────────────────── */ + +/* The cascade that walks the hero in, ~1.9s–3.4s after mount. */ +@keyframes heroRise { from { opacity:0; transform:translateY(16px); } to { opacity:1; transform:translateY(0); } } +/* Scroll cue, 2.2s loop. */ +@keyframes cueDrop { 0%,100% { transform:translateY(0); opacity:0.55; } 50% { transform:translateY(5px); opacity:1; } } +/* heroWipe and heroChar are defined by the source but never referenced by + its markup — the wordmark scrambles instead. Kept out deliberately; + see components/landing-v5/Hero.tsx. */ + +/* v5 hero responsive rules. The bottom band collapses before the mid row, + and the two height rules trim the top padding on short viewports. */ +@media (max-width: 1180px) { + .landing-v5 .hero-bottom { grid-template-columns:1fr !important; gap:20px !important; } + .landing-v5 .hero-info { justify-self:start !important; } + .landing-v5 .hero-cue { justify-self:start !important; align-items:flex-start !important; } + .landing-v5 .hero-scrollcue { display:none !important; } +} +@media (max-width: 860px) { + .landing-v5 .hero-mid { grid-template-columns:1fr !important; justify-items:start !important; gap:24px !important; } +} +@media (max-width: 820px) { + .landing-v5 .hero-keys { flex-direction:column !important; gap:14px !important; } + .landing-v5 .hero-info { max-width:none !important; } + .landing-v5 .hero-lede { max-width:38ch !important; } +} +@media (max-height: 800px) { + .landing-v5 .hero-grid { padding-top:96px !important; gap:14px !important; } + .landing-v5 .hero-info p { font-size:12.5px !important; } +} +@media (max-height: 680px) { + .landing-v5 .hero-grid { padding-top:84px !important; } + .landing-v5 .hero-keys p { display:none !important; } +} + +/* v5 hover states (the source's `style-hover` attribute has no inline + React equivalent). The Ko-fi pill differs from v4: v5 tints the + background rather than swapping it to a solid cream. */ +.landing-v5 .ld-kofi:hover { border-color:#FF5E1A; background:rgba(255,94,26,0.14); } +.landing-v5 .ld-ghost:hover { border-color:#0C5638; color:#0C5638; } + +/* Gallery + feature lab hover states. */ +.landing-v5 .ld-galcard:hover { transform: translateY(-5px); } +.landing-v5 .ld-labclose:hover { background: #12201A; color: #FDFCF9; } +.landing-v5 .ld-post:hover { border-color: rgba(18,32,26,0.16); } +.landing-v5 .ld-jumpitem:hover { background: rgba(12,86,56,0.07); color: #12201A; } +.landing-v5 .ld-jumppill:hover { opacity: 0.75; } +.landing-v5 .ld-emailinput:focus { border-color: #0C5638; box-shadow: 0 0 0 3px rgba(12,86,56,0.12); } + +/* ── Companion pages (About, Team, Wiki, Gallery, News, FAQ) ─────── + Ported from the sibling design components. These sit on the WARM + paper palette, not the landing's cool one — that difference is + intentional and matches the app's existing public pages. + ────────────────────────────────────────────────────────────────── */ +@keyframes fadeUp { from { opacity:0; transform:translateY(14px); } to { opacity:1; transform:translateY(0); } } + +.cp-navlink:hover { color: #1a1814; } +.cp-menuitem:hover { background: rgba(27,108,66,0.07); color: #1a1814; } +.cp-kofi:hover { border-color: #FF5E1A; background: #FFF3EC; } +.cp-cta:hover { filter: brightness(1.12); } + +/* ── Feature-lab demos ───────────────────────────────────────────── + Ported from `FeatureLab.dc.html`. `labIn` is its entrance, `labSpin` + the busy rings, `labDot` the typing indicator. `labFlash` is defined + by the source but never referenced by its markup — not ported. + ────────────────────────────────────────────────────────────────── */ +@keyframes labIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } } +@keyframes labSpin { to { transform:rotate(360deg); } } +@keyframes labDot { 0%,60%,100% { opacity:0.25; transform:translateY(0); } 30% { opacity:1; transform:translateY(-3px); } } + +.landing-v5 .ld-labprimary:hover { background: #0C5638; } +.landing-v5 .ld-labrate:hover { transform: translateY(-2px); } +.landing-v5 .ld-labaction:hover { background: #E6F2E8; } +.landing-v5 .ld-labrecent:hover { border-color: #0E9E5A; } +.landing-v5 .ld-labsend:hover { background: #0E9E5A; } +.landing-v5 .ld-labupload:hover { background: #DCE7DE; } +.landing-v5 .ld-labreply:hover { border-color: #0E9E5A; background: #E6F2E8; } +.cp-newscard:hover { border-color: rgba(42,39,31,0.22); transform: translateY(-3px); } +@keyframes nodeDrift { 0%,100% { transform:translate(0,0); } 50% { transform:translate(10px,-13px); } } + +/* Beta modal — recovered from the previous landing page. */ +.ld-betaclose:hover { background: rgba(107,114,128,0.1); } +.ld-betasubmit:hover:not(:disabled) { background: var(--brand-forest-hover) !important; } +.ld-betainput:focus { border-color: var(--brand-forest); box-shadow: 0 0 0 3px rgba(27,108,66,0.12); outline: none; } diff --git a/frontend/src/components/companion/CompanionShell.tsx b/frontend/src/components/companion/CompanionShell.tsx new file mode 100644 index 00000000..f3b3a866 --- /dev/null +++ b/frontend/src/components/companion/CompanionShell.tsx @@ -0,0 +1,172 @@ +'use client'; + +/** + * The chrome shared by the six companion pages (About, Team, Wiki, Gallery, + * News, FAQ). + * + * Ported from the sibling `.dc.html` files in the design import. All six + * repeat the same sticky header and footer verbatim; only the middle + * changes, so it lives here once. + * + * Note these pages use the WARM paper palette (#f4f1ea / #1a1814 / #1B6C42), + * not the landing page's cool one. That is deliberate in the design and + * matches the app's existing public pages — do not "unify" them. + */ + +import Image from 'next/image'; +import Link from 'next/link'; + +const SANS = "'DM Sans',system-ui,sans-serif"; + +/** Page order as it appears in the nav. `/` is Home. */ +export const COMPANION_NAV = [ + { label: 'Home', href: '/' }, + { label: 'About', href: '/about' }, + { label: 'Team', href: '/team' }, + { label: 'Wiki', href: '/wiki' }, + { label: 'Gallery', href: '/gallery' }, + { label: 'News', href: '/news' }, + { label: 'FAQ', href: '/faq' }, +]; + +const GITHUB_URL = 'https://github.com/SaplingLearn/Sapling'; +const KOFI_URL = 'https://ko-fi.com/saplinglearn'; + +const TAB: React.CSSProperties = { + fontFamily: SANS, fontSize: 13.5, letterSpacing: '0.02em', + transition: 'color 300ms', whiteSpace: 'nowrap', +}; + +const FOOTER_LINK: React.CSSProperties = { fontSize: 14, color: '#6f6857' }; + +export function CompanionShell({ + current, + children, +}: { + /** href of the page being rendered, so its tab can claim aria-current. */ + current: string; + children: React.ReactNode; +}) { + return ( +
+ {/* Geometry here is locked to the landing navbar: same horizontal + padding, same 16px/16px vertical padding, same 92px masked scrim. + The companion sources inset their bar to `min(1320px,92%)`, which + lands it ~32px further in than the landing bar and 18px taller — so + the mark visibly jumped when you navigated off `/`. Full-bleed wins; + if you change `max(4.2vw,22px)` here, change it in Navbar.tsx and the + hero grid too. Only the palette stays warm. */} +
+
+ + {children} + +
+
+
+ Sapling + Sapling · © 2026 +
+
+ {COMPANION_NAV.map((n) => ( + {n.label} + ))} + GitHub + Ko-fi + {/* The design's companion footer stops at the nav links. These three + are ours: /about used to be the only page linking them, so + folding it into this shell would otherwise orphan /careers + outright and leave the legal pages unreachable from any + companion page. */} + Careers + Terms of Service + Privacy Policy +
+
+
+
+ ); +} diff --git a/frontend/src/components/landing-v5/ActGraph.tsx b/frontend/src/components/landing-v5/ActGraph.tsx new file mode 100644 index 00000000..5ee1a052 --- /dev/null +++ b/frontend/src/components/landing-v5/ActGraph.tsx @@ -0,0 +1,360 @@ +'use client'; + +/** + * Act I — the knowledge graph, as scroll cinema. + * + * Ported from `Sapling Landing v5.dc.html`. A 460vh section whose inner + * sticky 100vh stage holds a canvas the engine draws the graph into. Four + * captions cross-fade across the scroll; clicking the canvas swaps the whole + * act into an orbitable explore mode with a concept inspector. + * + * The canvas is the only thing that moves per frame, and the engine owns it. + * Everything here is either static chrome or React state that changes at + * human speed. + */ + +import { + COURSE, XTIER, XTIER_LABEL, XTIER_ORDER, nodeUses, + type MasteryTier, +} from '@/lib/landing/course'; +import type { BuiltGraph } from '@/lib/landing/engine/graph'; + +const CAP_LABEL = "The graph"; + +/** The four caption stages, in scroll order. */ +const CAPTIONS = [ + { + eyebrow: null, + head: <>A hundred names you don’t know yet., + body: "Every concept in your course becomes a node. Right now, they're strangers.", + }, + { + eyebrow: CAP_LABEL, + head: <>You study. They connect., + body: 'Every quiz, note, and card you touch draws an edge. Structure appears.', + }, + { + eyebrow: CAP_LABEL, + head: <>Mastery is the color., + body: null, + }, +]; + +/** The glow plate behind each caption, so copy stays legible over the graph. */ +const CAP_SCRIM: React.CSSProperties = { + position: 'absolute', left: -58, right: -72, top: -46, bottom: -46, + pointerEvents: 'none', + background: + 'radial-gradient(ellipse 62% 58% at 38% 50%, rgba(4,18,12,0.82) 0%, rgba(4,18,12,0.66) 45%, rgba(4,18,12,0.28) 72%, rgba(4,18,12,0) 100%)', + filter: 'blur(18px)', +}; + +const CAP_BOX: React.CSSProperties = { + position: 'absolute', left: 'max(6vw,32px)', top: '50%', + transform: 'translateY(-50%)', zIndex: 4, maxWidth: '34ch', + opacity: 0, pointerEvents: 'none', +}; + +const EYEBROW: React.CSSProperties = { + position: 'relative', display: 'inline-block', + fontFamily: "'JetBrains Mono',monospace", fontSize: 10.5, + letterSpacing: '0.34em', color: '#8FD9A8', textTransform: 'uppercase', + textShadow: '0 1px 10px rgba(4,18,12,0.9)', +}; + +const CAP_H2: React.CSSProperties = { + position: 'relative', margin: '18px 0 0', fontFamily: "'Playfair Display',serif", + fontSize: 'clamp(2.4rem,4.6vw,4rem)', fontWeight: 600, lineHeight: 1.06, + letterSpacing: '-0.02em', color: '#F6F8F4', + textShadow: '0 2px 26px rgba(4,18,12,0.85)', +}; + +const CAP_P: React.CSSProperties = { + position: 'relative', margin: '16px 0 0', color: '#B9D9C4', + fontSize: 15.5, lineHeight: 1.7, textShadow: '0 1px 14px rgba(4,18,12,0.9)', +}; + +const MONO_TINY: React.CSSProperties = { + fontFamily: "'JetBrains Mono',monospace", fontSize: 8.5, + letterSpacing: '0.22em', color: '#7E9689', +}; + +/** Four blurred blobs that read as out-of-focus foliage at the frame edges. */ +const FOLIAGE = [ + { style: { left: '-16%', top: '-12%', width: '46vw', height: '40vw', borderRadius: '48% 62% 55% 45%', background: 'radial-gradient(ellipse at 30% 30%, rgba(5,22,14,0.95), rgba(5,22,14,0) 68%)', filter: 'blur(34px)' } }, + { style: { right: '-18%', top: '-8%', width: '44vw', height: '38vw', borderRadius: '55% 45% 60% 50%', background: 'radial-gradient(ellipse at 70% 30%, rgba(4,18,12,0.92), rgba(4,18,12,0) 68%)', filter: 'blur(38px)' } }, + { style: { left: '-14%', bottom: '-14%', width: '44vw', height: '40vw', borderRadius: '60% 50% 45% 62%', background: 'radial-gradient(ellipse at 35% 60%, rgba(4,18,12,0.95), rgba(4,18,12,0) 68%)', filter: 'blur(36px)' } }, + { style: { right: '-15%', bottom: '-12%', width: '46vw', height: '42vw', borderRadius: '50% 60% 52% 48%', background: 'radial-gradient(ellipse at 65% 62%, rgba(5,22,14,0.92), rgba(5,22,14,0) 68%)', filter: 'blur(40px)' } }, +]; + +export function ActGraph({ + actCanvasRef, + cinemaRef, + graph, + exploring, + expNode, + onSelectNode, + onExitExplore, + onQuiz, + onLearn, +}: { + actCanvasRef: React.RefObject; + cinemaRef: React.RefObject; + /** Null until the engine has mounted and published its instance. */ + graph: BuiltGraph | null; + exploring: boolean; + expNode: number | null; + onSelectNode: (i: number | null) => void; + onExitExplore: () => void; + onQuiz: () => void; + onLearn: () => void; +}) { + const nodes = graph?.nodes ?? []; + // node 0 is the course itself, so it doesn't count toward the tally + const counts = XTIER_ORDER.reduce((acc, t) => { + acc[t] = nodes.filter((n, i) => i > 0 && n.tier === t).length; + return acc; + }, {} as Record); + + const node = expNode !== null && nodes[expNode] ? nodes[expNode] : null; + const neighbours = node && graph ? [...new Set(graph.adj[expNode!] ?? [])] : []; + + return ( +
+
+ + + + + {/* captions — the engine cross-fades these by scroll progress */} +
+ {CAPTIONS.map((c, i) => ( +
+
+ ))} + + {/* the closing caption sits low and centred rather than left-aligned */} +
+
+ + {/* stage rail — the engine lights the tick matching the live caption */} + +
+ + {/* ── explore HUD ── */} + {exploring && ( +
+
+ {COURSE.code} + {COURSE.name} +
+ +
+ + {COURSE.term} · {Math.max(0, nodes.length - 1)} CONCEPTS MAPPED + + {XTIER_ORDER.map((t) => ( + + + {XTIER_LABEL[t]} + {counts[t]} + + ))} +
+ + + DRAG TO ORBIT · SCROLL TO ZOOM · CLICK A CONCEPT + + + {node && ( +
+
+ + + {XTIER_LABEL[node.tier].toUpperCase()} + + + {node.root + ? 'COURSE' + : node.hub + ? `UNIT · ${COURSE.code}` + : `${COURSE.code} · ${COURSE.topics[node.topic!].label.toUpperCase()}`} + + +
+

{node.label}

+

{node.blurb}

+ +
+ + + + {Math.round(node.mastery * 100)}% +
+ + MASTERY SCORE · UPDATED AFTER EVERY SESSION + + + + {neighbours.length} CONNECTED {neighbours.length === 1 ? 'CONCEPT' : 'CONCEPTS'} + +
+ {neighbours.map((j) => ( + + ))} +
+ + WHAT SAPLING DOES WITH THIS NODE +
+ {nodeUses(node, neighbours.length).map((u, i) => ( +
+ {u.tag} + {u.text} +
+ ))} +
+ +
+ + +
+ + /learn?topic={encodeURIComponent(node.label)}&mode=socratic + +
+ )} + + +
+ )} +
+
+ ); +} + +/** The band that lifts back out of the dark act into the light sections. */ +export function RiseBand() { + return ( +