feat(landing): add scroll-spy nav, motion, and a11y polish - #66
Merged
Conversation
…n tokens - web/components/SetupTabs.module.css existed on disk and was already imported by SetupTabs.tsx, but had never been added to git, so a fresh checkout of the repository was missing a file required for the setup tabs to render - rewrite its custom properties to reference the actual tokens defined in globals.css (--color-border, --color-text-muted, --color-accent-pink, --space-*, --dur-*, --ease-standard, etc.) instead of undefined variables with light-theme hex fallbacks, which previously caused the section to silently fall back to colors that don't match the rest of the dark-themed site
- CodeBlock.test.tsx: shiki wraps each syntax-highlighted token in its own element, so React Testing Library's default text matcher (which only reads an element's direct text-node children) can never match a multi-token phrase like "pip install -e ." or " line 1"; assert on the rendered container's full text content instead - CodeBlock.test.tsx: drop the now-unnecessary @ts-expect-error on the RSC-resolution helper (invoking element.type via a cast type-checks cleanly, so the suppression comment was flagged as unused) - highlight.test.ts: same shiki token-splitting issue — strip tags before asserting the highlighted output still contains the source text - scaffold.test.tsx: mock next/font/google, @/components/CodeBlock, and next/navigation before importing the root layout and landing page, the same way app/page.test.tsx already does, so rendering Home doesn't trip over an unmocked Google Fonts loader or an un-renderable async Server Component; also fix an assertion that checked for literal "Lychee" text the Home page has never actually rendered, in favor of the canonical tagline
- introduce web/styles/animations.css with the fade-up keyframes (used by section-entry reveals and the pipeline stage stagger) and the badge-pulse keyframes (the one-shot ripeness badge animation), plus an explicit prefers-reduced-motion override on top of the existing global guard in globals.css - import animations.css from the root layout, after globals.css, so the keyframes and the .badge-pulse utility class are available site-wide
- add web/lib/useScrollSpy.ts, an IntersectionObserver-backed hook that tracks which of a list of section ids is most visible in the viewport; it only observes ids actually present in the DOM and no-ops when IntersectionObserver or window isn't available, so it's safe to call unconditionally from a component rendered on every route - wire useScrollSpy into NavBar with the eight landing sections (SCROLL_SPY_SECTION_IDS), so the nav highlights whichever section is in view; an explicit activeId prop still overrides the live reading, which existing and new tests rely on - fix the active-link style to use --color-accent-pink with an underline (the CSS previously referenced an undefined --color-primary token, so the active state never actually rendered any visible accent) - intercept clicks on in-page anchors to scroll smoothly to their target section and update the URL hash via history.pushState, instead of relying solely on the browser's default jump; links to other routes, external links, and anchors whose target isn't on the current page are left to navigate normally - polyfill Element.prototype.scrollIntoView in the Vitest setup file, since jsdom doesn't implement it and components now call it
- add web/components/ScrollReveal.tsx, an IntersectionObserver-backed wrapper that adds a "revealed" class the first time its contents scroll into view, triggering the fade-up keyframes from animations.css; an optional delayMs prop staggers multiple reveals via animation-delay - children stay fully visible before the observer fires and when IntersectionObserver isn't available, so content never depends on JavaScript to be readable - wrap each landing section after the hero (features, how it works, setup, output, commands, configuration, contribute) in ScrollReveal from app/page.tsx; wrapping there — rather than inside each section component — keeps every individual section's own tests and markup unchanged - drop FeatureHighlights' now-redundant .fadeUp placeholder class and comment, since ScrollReveal is the actual reveal mechanism now
- wrap every section after the hero (features, how it works, setup, output, commands, configuration, contribute) in ScrollReveal from app/page.tsx, so each one fades up the first time it scrolls into view - drop FeatureHighlights' now-redundant .fadeUp placeholder class and comment, since ScrollReveal is the actual reveal mechanism now
- add the badge-pulse class from animations.css to each ripeness badge; the animation runs once (animation-iteration-count: 1) as soon as the badges paint, with no JavaScript required, and is disabled entirely under prefers-reduced-motion
…o view - observe the pipeline's <ol> with IntersectionObserver; once it enters the viewport, apply an inView class that triggers each stage card's fade-up animation, staggered 80ms apart by stage index via an inline animation-delay - stages stay fully visible before the observer fires and when IntersectionObserver is unavailable, matching the rest of the site's progressive-enhancement approach to motion - replace the stage card's dangling transition referencing undefined --motion-duration-slow/--motion-easing-default tokens with the actual reveal animation, built on the site's real --dur-slow/--ease-standard tokens, plus an explicit prefers-reduced-motion override
- add @playwright/test for end-to-end coverage and @vitest/coverage-v8 for statement/branch/function/line coverage reporting, plus test:e2e and test:coverage npm scripts - configure Vitest's coverage provider with an 80% threshold across statements, branches, functions, and lines over app/, components/, lib/, and styles/ - give the full-page axe scans in app/page.test.tsx and lib/scaffold.test.tsx a longer timeout — auditing the fully assembled Home page is slow enough under coverage instrumentation to occasionally exceed Vitest's default 5s test timeout
- add web/app/a11y.test.tsx: jest-axe scans of the fully assembled landing page (Home) and the docs index page, asserting no violations - assert the same landing page still passes its axe scan when window.matchMedia reports prefers-reduced-motion: reduce, and that its ScrollReveal-wrapped sections render fully visible rather than depending on the reveal animation to become visible
- add playwright.config.ts, targeting a local next dev server on port 3100 and starting it automatically for test runs - add web/e2e/landing.spec.ts covering: no console errors and the canonical document title on load; clicking the Setup nav link smooth-scrolls to the section and updates the URL hash; the active nav link changes as different sections scroll into view; the hero badges carry the one-shot pulse class; and, at a 375px viewport, no horizontal body overflow, the hamburger toggling the mobile menu, and the deployment table sitting in a horizontally-scrollable wrapper - ignore Playwright's test-results/, playwright-report/, blob-report/, and cache directories
…ties - next build's lint step fails on plain <a> tags with a static internal href (@next/next/no-html-link-for-pages); swap the brand link, Hero's primary CTA, and internal doc links in Footer, SetupTabs, and ConfigSamplerClient for next/link's <Link>, preserving all existing hrefs, classes, and (for NavBar's brand link) the smooth-scroll click handler - escape a stray apostrophe in ContributeSection and OutputShowcase (react/no-unescaped-entities) using ', which renders as the exact same character so the visible copy is unchanged - these lint rules block `next build` outright; none of the affected lines were touched by anything else in this branch
- Next.js 15 (installed via the dependabot next-15.5.18 bump) requires
dynamic route params to be typed and accessed as a Promise; this route
still declared params as a plain { slug: string } object, which fails
Next's generated route type check during `next build`
- type DocPageRouteProps.params as Promise<{ slug: string }> and await it
in both generateMetadata and the page component before reading slug
- update the corresponding test call sites to pass Promise.resolve(...)
for params, matching the new contract
- Next.js validates that a page.tsx module only exports the route's recognized special names (default, metadata, dynamic, etc.); the extra named export DOCS_INDEX_GROUPS fails that check during `next build`'s type-checking step - move DOCS_INDEX_GROUPS, DocsIndexGroup, and DOC_LABELS into a new app/docs/docs-index-groups.ts module; the page imports them for rendering without re-exporting them, and the page's test imports DOCS_INDEX_GROUPS directly from the new module
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The landing page had every section in place, but nothing about scrolling through it responded: the nav didn't track where you were, nothing animated, and no one had run an accessibility or mobile pass since the sections landed. This PR closes that out. It also fixes a production build that had never actually succeeded.
Navigation
The nav bar now tracks which section is in view and highlights it live, using an
IntersectionObserver-backed hook (useScrollSpy) instead of scroll-position math. It watches the eight landing sections in document order and reports whichever one is most visible. On routes that don't have those sections (the docs pages), it does nothing. An explicitactiveIdprop still overrides the live reading, so anything that pins a specific active link for testing keeps working.Clicking an in-page nav link now scrolls smoothly to its target and updates the URL hash via
history.pushState, rather than leaving it entirely to the browser's default jump. Links to other routes, external links, and anchors whose target isn't on the current page are left alone.The active link's accent color was also broken: it referenced a CSS variable,
--color-primary, that was never defined, so the "active" state rendered with no visible change at all. It now uses the pink accent with an underline.Motion
A
ScrollRevealcomponent wraps every section after the hero and fades it up the first time it scrolls into view, built on shared keyframes in a newanimations.css. Sections are fully visible before JavaScript runs, and stay that way ifIntersectionObserverisn't available. The animation is additive; it's never a requirement for content to be readable.The pipeline diagram's seven stages now reveal one after another as the diagram scrolls into view, staggered 80ms apart by stage index. The ripeness badges in the hero get a one-shot pulse on load (
animation-iteration-count: 1, no loop). All three of these, along with the section fade-up, are disabled underprefers-reduced-motion: reduce. There's an explicit override for them inanimations.css, on top of the site's existing global guard.Accessibility and mobile
Added a dedicated axe pass over the assembled landing page and the docs index, plus a check that the landing page still passes axe with
prefers-reduced-motionmocked true, and thatScrollReveal's sections render fully visible regardless of motion preference.Playwright is now set up for end-to-end coverage, which didn't exist before. Seven specs check: the page loads with no console errors and the correct title; clicking the Setup link updates the hash and scrolls the section into view; the active nav link changes as different sections scroll through; the badges carry the pulse class on load; and, at a 375px viewport, the body has no horizontal overflow, the hamburger toggles the mobile menu, and the deployment table sits in a horizontally scrollable wrapper instead of overflowing the page.
Along the way
None of this was the point of the PR, but each item blocked either a green test suite or an actual working build, so it's fixed here instead of left for someone to trip over later.
SetupTabs.module.cssexisted on disk and was already imported by its component, but had never been committed. A fresh clone was missing a file the setup section needs to render at all. It's committed now, with its custom properties rewritten to reference real design tokens instead of undefined variables that were silently falling back to light-theme hex values.CodeBlockandhighlightsuites, asserted against text that shiki splits across multiple<span>elements, which Testing Library's default matcher can't see across. The third,scaffold.test.tsx, was missing mocks fornext/font/google,CodeBlock, andnext/navigation, and separately asserted on text ("Lychee") the Home page has never actually rendered.npm run buildhad never completed successfully with real network access. Chasing it down turned up three unrelated problems: about a dozen<a>tags with a static internalhref, which the Next.js ESLint plugin requires to be<Link>, plus two unescaped apostrophes; the dynamic docs route still typedparamsas a plain object instead of thePromiseNext.js 15 requires; and the docs index route exportedDOCS_INDEX_GROUPSdirectly, which Next's route-export validation rejects. It now lives in its own module.Known issue
next buildstill fails prerendering/docs/[slug], with "A React Element from an older version of React was rendered" thrown from inside next-mdx-remote's MDX compilation. React itself is deduped to one version across the whole tree, so this looks like a next-mdx-remote / Next 15 / React 18 compatibility issue in the MDX pipeline, not anything this PR touches.next dev,next lint,tsc --noEmit, and the full test suite are all unaffected, and were used for every functional check in this PR, including the Playwright specs and manual screenshots at 375px and 1280px. Leaving this for a follow-up rather than widening the diff further.Testing
next devtsc --noEmitandnext lintclean, aside from one pre-existing, non-blocking warning about using<img>instead ofnext/image