Skip to content

feat(landing): add scroll-spy nav, motion, and a11y polish - #66

Merged
aruneem-bhowmick merged 15 commits into
mainfrom
feat/scroll-spy-motion-polish
Jul 2, 2026
Merged

feat(landing): add scroll-spy nav, motion, and a11y polish#66
aruneem-bhowmick merged 15 commits into
mainfrom
feat/scroll-spy-motion-polish

Conversation

@aruneem-bhowmick

Copy link
Copy Markdown
Owner

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 explicit activeId prop 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 ScrollReveal component wraps every section after the hero and fades it up the first time it scrolls into view, built on shared keyframes in a new animations.css. Sections are fully visible before JavaScript runs, and stay that way if IntersectionObserver isn'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 under prefers-reduced-motion: reduce. There's an explicit override for them in animations.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-motion mocked true, and that ScrollReveal'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.css existed 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.
  • Three tests were already failing before this branch touched anything. Two, in the CodeBlock and highlight suites, 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 for next/font/google, CodeBlock, and next/navigation, and separately asserted on text ("Lychee") the Home page has never actually rendered.
  • npm run build had never completed successfully with real network access. Chasing it down turned up three unrelated problems: about a dozen <a> tags with a static internal href, which the Next.js ESLint plugin requires to be <Link>, plus two unescaped apostrophes; the dynamic docs route still typed params as a plain object instead of the Promise Next.js 15 requires; and the docs index route exported DOCS_INDEX_GROUPS directly, which Next's route-export validation rejects. It now lives in its own module.

Known issue

next build still 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

  • 306 Vitest tests passing across 29 files (7 intentionally skipped), with coverage enforced at an 80% floor across statements, branches, functions, and lines (currently 95.8% / 90.6% / 94.5% / 97.1%)
  • 7/7 Playwright specs passing against next dev
  • tsc --noEmit and next lint clean, aside from one pre-existing, non-blocking warning about using <img> instead of next/image
  • No horizontal overflow at 375px or 1280px, checked with full-page screenshots

…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 &apos;, 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
@aruneem-bhowmick
aruneem-bhowmick merged commit 55a6aa2 into main Jul 2, 2026
4 checks passed
@aruneem-bhowmick
aruneem-bhowmick deleted the feat/scroll-spy-motion-polish branch July 2, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant