Assist_Design/docs/plans/2026-03-05-parallax-pinned-chapters-plan.md
barsa 57f2c543d1 style: update typography and layout across components
- Replaced font references in globals.css to use DM Sans and JetBrains Mono for improved typography consistency.
- Adjusted various components to utilize the new font styles, enhancing visual hierarchy and readability.
- Updated layout properties in AppShell and Sidebar for better alignment and spacing.
- Enhanced button styles to include a new subtle variant for improved UI flexibility.
- Refactored SearchFilterBar to support active filter display, improving user interaction experience.
- Made minor adjustments to the DashboardView and landing page components for better visual consistency.
2026-03-06 10:45:51 +09:00

17 KiB

Parallax Pinned Chapters + Snap Carousel Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Redesign the landing page with sticky "chapter" sections that pin and stack as users scroll, plus a native CSS scroll-snap carousel for services.

Architecture: Landing page sections grouped into 4 chapters wrapped in sticky containers with ascending z-index. Each chapter slides over the previous one. The services carousel is rebuilt from JS-driven absolute positioning to native CSS scroll-snap. A useSnapCarousel hook manages scroll state, auto-play, and indicator sync.

Tech Stack: Next.js 15, React 19, Tailwind CSS v4 (with @utility), CSS position: sticky, CSS scroll-snap-type

Design doc: docs/plans/2026-03-05-parallax-pinned-chapters-design.md


Task 1: Create the Chapter wrapper component

Files:

  • Create: apps/portal/src/features/landing-page/components/Chapter.tsx
  • Modify: apps/portal/src/features/landing-page/components/index.ts

Step 1: Create Chapter.tsx

// apps/portal/src/features/landing-page/components/Chapter.tsx
import { cn } from "@/shared/utils";

interface ChapterProps {
  children: React.ReactNode;
  zIndex: number;
  className?: string;
  overlay?: boolean;
}

export function Chapter({ children, zIndex, className, overlay = false }: ChapterProps) {
  return (
    <section
      className={cn(
        "sticky top-0 will-change-transform",
        "motion-safe:sticky motion-reduce:relative",
        overlay && "shadow-[0_-8px_30px_-10px_rgba(0,0,0,0.08)]",
        className
      )}
      style={{ zIndex }}
    >
      {children}
    </section>
  );
}

Step 2: Add export to components/index.ts

Add this line to apps/portal/src/features/landing-page/components/index.ts:

export { Chapter } from "./Chapter";

Step 3: Verify types compile

Run: pnpm type-check Expected: PASS (no type errors)

Step 4: Commit

feat: add Chapter sticky wrapper component

Task 2: Create the useSnapCarousel hook

Files:

  • Create: apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts
  • Modify: apps/portal/src/features/landing-page/hooks/index.ts

Step 1: Create useSnapCarousel.ts

This hook manages a CSS scroll-snap carousel: tracks the active index via scroll position, provides goTo/goNext/goPrev via scrollTo, handles auto-play with pause-on-interaction, and syncs dot indicators.

// apps/portal/src/features/landing-page/hooks/useSnapCarousel.ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

interface UseSnapCarouselOptions {
  total: number;
  autoPlayMs?: number;
}

export function useSnapCarousel({ total, autoPlayMs = 10000 }: UseSnapCarouselOptions) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const [activeIndex, setActiveIndex] = useState(0);

  // Auto-play pause tracking
  const pausedRef = useRef(false);
  const pauseTimerRef = useRef<ReturnType<typeof setTimeout>>();

  const pauseAutoPlay = useCallback(() => {
    pausedRef.current = true;
    clearTimeout(pauseTimerRef.current);
    pauseTimerRef.current = setTimeout(() => {
      pausedRef.current = false;
    }, autoPlayMs * 2);
  }, [autoPlayMs]);

  // Sync activeIndex from scroll position
  useEffect(() => {
    const container = scrollRef.current;
    if (!container) return;

    const onScroll = () => {
      const scrollLeft = container.scrollLeft;
      const cardWidth = container.offsetWidth;
      if (cardWidth === 0) return;
      const index = Math.round(scrollLeft / cardWidth);
      setActiveIndex(Math.min(index, total - 1));
    };

    container.addEventListener("scroll", onScroll, { passive: true });
    return () => container.removeEventListener("scroll", onScroll);
  }, [total]);

  const scrollToIndex = useCallback((index: number) => {
    const container = scrollRef.current;
    if (!container) return;
    const cardWidth = container.offsetWidth;
    container.scrollTo({ left: index * cardWidth, behavior: "smooth" });
  }, []);

  const goTo = useCallback(
    (index: number) => {
      pauseAutoPlay();
      scrollToIndex(index);
    },
    [pauseAutoPlay, scrollToIndex]
  );

  const goNext = useCallback(() => {
    pauseAutoPlay();
    const next = (activeIndex + 1) % total;
    scrollToIndex(next);
  }, [activeIndex, total, pauseAutoPlay, scrollToIndex]);

  const goPrev = useCallback(() => {
    pauseAutoPlay();
    const prev = (activeIndex - 1 + total) % total;
    scrollToIndex(prev);
  }, [activeIndex, total, pauseAutoPlay, scrollToIndex]);

  const reset = useCallback(() => {
    const container = scrollRef.current;
    if (!container) return;
    container.scrollTo({ left: 0, behavior: "instant" });
    setActiveIndex(0);
  }, []);

  // Auto-play
  useEffect(() => {
    if (total <= 1) return;
    const id = setInterval(() => {
      if (pausedRef.current) return;
      const container = scrollRef.current;
      if (!container) return;
      const cardWidth = container.offsetWidth;
      const currentIndex = Math.round(container.scrollLeft / cardWidth);
      const next = (currentIndex + 1) % total;
      container.scrollTo({ left: next * cardWidth, behavior: "smooth" });
    }, autoPlayMs);
    return () => clearInterval(id);
  }, [total, autoPlayMs]);

  // Pause on touch/pointer interaction
  const onPointerDown = useCallback(() => {
    pauseAutoPlay();
  }, [pauseAutoPlay]);

  // Cleanup
  useEffect(() => {
    return () => clearTimeout(pauseTimerRef.current);
  }, []);

  // Keyboard navigation
  const onKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "ArrowLeft") goPrev();
      else if (e.key === "ArrowRight") goNext();
    },
    [goPrev, goNext]
  );

  return {
    scrollRef,
    activeIndex,
    total,
    goTo,
    goNext,
    goPrev,
    reset,
    onPointerDown,
    onKeyDown,
  };
}

Step 2: Update hooks/index.ts

Add this export to apps/portal/src/features/landing-page/hooks/index.ts:

export { useSnapCarousel } from "./useSnapCarousel";

Step 3: Verify types compile

Run: pnpm type-check Expected: PASS

Step 4: Commit

feat: add useSnapCarousel hook for CSS scroll-snap carousel

Task 3: Rebuild ServicesCarousel with CSS scroll-snap

Files:

  • Modify: apps/portal/src/features/landing-page/components/ServicesCarousel.tsx

Context: The current carousel uses absolute positioning with JS transform calculations (getCircularOffset, translateX(${offset * 102}%)). Replace with a native horizontal scroll container using CSS scroll-snap.

Step 1: Rewrite ServicesCarousel.tsx

Key changes:

  • Replace useCarousel with useSnapCarousel
  • Replace the absolute-positioned card layout with a horizontal flex scroll container
  • Add scroll-snap-type: x mandatory on the container
  • Add scroll-snap-align: center on each card
  • Each card is min-w-full (takes full width of the scroll viewport)
  • Remove the invisible sizer div hack
  • Remove onTouchStart/onTouchEnd (native scroll handles swipe)
  • Remove getCircularOffset helper
  • Keep: CarouselHeader, CarouselNav, ServiceCard (unchanged), ACCENTS map
  • Keep: useInView for the section fade-in animation
  • Remove the full-bleed class from the section (Chapter wrapper will handle full-width)

The scroll container structure:

<div
  ref={c.scrollRef}
  className="flex overflow-x-auto snap-x snap-mandatory scrollbar-hide"
  onPointerDown={c.onPointerDown}
  onKeyDown={c.onKeyDown}
  tabIndex={0}
  role="region"
  aria-label="Services carousel"
  aria-roledescription="carousel"
>
  {cards.map((card, i) => (
    <div
      key={`${card.title}-${i}`}
      className="min-w-full snap-center px-6 sm:px-10"
      role="group"
      aria-roledescription="slide"
      aria-label={`${i + 1} of ${cards.length}: ${card.title}`}
    >
      <div className="mx-auto max-w-3xl">
        <ServiceCard card={card} />
      </div>
    </div>
  ))}
</div>

Also add scrollbar-hide utility if not already present. Check apps/portal/src/styles/utilities.css for existing scrollbar-hide. If missing, add to the same file:

@utility scrollbar-hide {
  -ms-overflow-style: none;
  scrollbar-width: none;
  &::-webkit-scrollbar {
    display: none;
  }
}

Step 2: Remove full-bleed from section

The section should no longer use full-bleed since the Chapter wrapper handles the full-width layout. Change the section's className from:

"full-bleed bg-surface-sunken/30 py-16 sm:py-20 ..."

to:

"bg-surface-sunken/30 py-16 sm:py-20 ..."

Step 3: Wire up useSnapCarousel

const c = useSnapCarousel({ total: cards.length, autoPlayMs: 10000 });

Call c.reset() when activeTab changes (same as before).

Step 4: Verify types compile

Run: pnpm type-check Expected: PASS

Step 5: Commit

refactor: rebuild ServicesCarousel with CSS scroll-snap

Task 4: Update HeroSection and TrustStrip for Chapter 1

Files:

  • Modify: apps/portal/src/features/landing-page/components/HeroSection.tsx
  • Modify: apps/portal/src/features/landing-page/components/TrustStrip.tsx

Context: Chapter 1 wraps Hero + TrustStrip. The chapter is min-h-dvh flex flex-col. Hero should flex-grow to fill available space, TrustStrip anchors at the bottom.

Step 1: Update HeroSection

  • Remove full-bleed from the section (Chapter wrapper handles full-width)
  • Add flex-1 so it expands to fill the chapter
  • Change the section to a div (the Chapter <section> is the semantic wrapper now)
  • Keep the gradient background, dot pattern, and all content unchanged

Change:

<section ref={heroRef} className={cn("full-bleed py-16 sm:py-20 lg:py-24 ...")}>

To:

<div ref={heroRef} className={cn("relative flex-1 flex items-center py-16 sm:py-20 lg:py-24 ...")}>

Step 2: Update TrustStrip

  • Remove full-bleed from the section
  • Change to a div (Chapter is the semantic <section>)
  • Keep all content and animation unchanged

Change:

<section ref={ref} aria-label="Company statistics" className={cn("full-bleed py-10 sm:py-12 ...")}>

To:

<div ref={ref} aria-label="Company statistics" className={cn("relative py-10 sm:py-12 ...")}>

Step 3: Verify types compile

Run: pnpm type-check Expected: PASS

Step 4: Commit

refactor: adjust HeroSection and TrustStrip for Chapter 1 layout

Task 5: Update WhyUsSection and CTABanner for Chapter 3

Files:

  • Modify: apps/portal/src/features/landing-page/components/WhyUsSection.tsx
  • Modify: apps/portal/src/features/landing-page/components/CTABanner.tsx

Step 1: Update WhyUsSection

  • Remove full-bleed from the section
  • Change <section> to <div>
  • Keep all content unchanged

Step 2: Update CTABanner

  • Remove full-bleed from the section
  • Change <section> to <div>
  • Keep all content unchanged

Step 3: Verify types compile

Run: pnpm type-check Expected: PASS

Step 4: Commit

refactor: adjust WhyUsSection and CTABanner for Chapter 3 layout

Task 6: Update SupportDownloadsSection and ContactSection for Chapter 4

Files:

  • Modify: apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx
  • Modify: apps/portal/src/features/landing-page/components/ContactSection.tsx

Step 1: Update ContactSection

  • Remove full-bleed from the section
  • Change <section> to <div> (or keep as <section> since Chapter 4 is a plain <div>)

Actually, Chapter 4 is NOT sticky (it's the last chapter, just a plain wrapper). So ContactSection and SupportDownloadsSection can remain as <section> elements. But still remove full-bleed since the Chapter 4 wrapper handles layout.

  • ContactSection: remove full-bleed from className
  • SupportDownloadsSection: no full-bleed to remove (it doesn't use it), no changes needed

Step 2: Verify types compile

Run: pnpm type-check Expected: PASS

Step 3: Commit

refactor: adjust ContactSection for Chapter 4 layout

Task 7: Wire up PublicLandingView with Chapter wrappers

Files:

  • Modify: apps/portal/src/features/landing-page/views/PublicLandingView.tsx

Context: This is the main composition file. Wrap section groups in Chapter components.

Step 1: Rewrite PublicLandingView.tsx

"use client";

import { ArrowRight } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { useStickyCta } from "@/features/landing-page/hooks";
import {
  Chapter,
  HeroSection,
  TrustStrip,
  ServicesCarousel,
  WhyUsSection,
  CTABanner,
  SupportDownloadsSection,
  ContactSection,
} from "@/features/landing-page/components";

export function PublicLandingView() {
  const { heroCTARef, showStickyCTA } = useStickyCta();

  return (
    <div className="pb-8">
      {/* Chapter 1: Who we are */}
      <Chapter zIndex={1} className="min-h-dvh flex flex-col bg-background">
        <HeroSection heroCTARef={heroCTARef} />
        <TrustStrip />
      </Chapter>

      {/* Chapter 2: What we offer */}
      <Chapter zIndex={2} overlay className="bg-surface-sunken/30">
        <ServicesCarousel />
      </Chapter>

      {/* Chapter 3: Why choose us */}
      <Chapter zIndex={3} overlay className="bg-background">
        <WhyUsSection />
        <CTABanner />
      </Chapter>

      {/* Chapter 4: Get in touch (no sticky, last chapter) */}
      <div className="relative bg-background" style={{ zIndex: 4 }}>
        <SupportDownloadsSection />
        <ContactSection />
      </div>

      {/* Sticky Mobile CTA */}
      {showStickyCTA && (
        <div className="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm border-t border-border p-4 z-50 md:hidden animate-in slide-in-from-bottom-4 duration-300">
          <Button
            as="a"
            href="/services"
            variant="pill"
            size="lg"
            rightIcon={<ArrowRight className="h-5 w-5" />}
            className="w-full shadow-lg"
          >
            Find Your Plan
          </Button>
        </div>
      )}
    </div>
  );
}

Key changes:

  • Each section group wrapped in Chapter with ascending zIndex
  • Chapter 1 gets min-h-dvh flex flex-col to fill viewport
  • Chapters 2 and 3 get overlay prop for the top shadow
  • Each Chapter gets a solid bg-* class so it covers the chapter behind
  • Chapter 4 is a plain div with zIndex: 4 and relative positioning
  • Removed space-y-0 pt-0 from outer container (chapters handle spacing)

Step 2: Verify types compile

Run: pnpm type-check Expected: PASS

Step 3: Commit

feat: wire up PublicLandingView with sticky Chapter layout

Task 8: Visual QA and polish

Files:

  • Potentially any of the above files for tweaks

Step 1: Run the dev server and test

Run: pnpm --filter @customer-portal/portal dev (with user permission)

Step 2: Visual checklist

Test in browser at localhost:3000:

  • Chapter 1 (Hero + TrustStrip) fills the viewport and pins
  • Scrolling down: Chapter 2 (Services) slides up and covers Chapter 1
  • Services carousel: swipe/drag snaps cards into place
  • Services carousel: dot indicators stay in sync
  • Services carousel: arrow buttons work
  • Services carousel: personal/business tab toggle resets to first card
  • Services carousel: auto-play advances cards
  • Chapter 3 (WhyUs + CTA) slides up and covers Chapter 2
  • Chapter 4 (Support + Contact) scrolls normally
  • Contact form is fully interactive (not blocked by sticky)
  • Sticky mobile CTA still appears when hero scrolls out of view
  • prefers-reduced-motion: sticky behavior disabled, normal scroll
  • No horizontal overflow / layout shifts
  • Test on mobile viewport (responsive)

Step 3: Fix any issues found

Adjust padding, backgrounds, shadows, z-index as needed.

Step 4: Run lint and type-check

Run: pnpm lint && pnpm type-check Expected: PASS

Step 5: Commit

style: polish parallax chapters and snap carousel

Task 9: Clean up deprecated code

Files:

  • Modify: apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts
  • Modify: apps/portal/src/features/landing-page/hooks/index.ts

Step 1: Check if useCarousel / useInfiniteCarousel is used elsewhere

Search for imports of useCarousel and useInfiniteCarousel across the codebase. If only used in the old ServicesCarousel (now replaced), remove the file.

Step 2: If unused, delete useInfiniteCarousel.ts

Remove the file and remove its exports from hooks/index.ts.

Step 3: Run lint and type-check

Run: pnpm lint && pnpm type-check Expected: PASS

Step 4: Commit

chore: remove unused useCarousel hook (replaced by useSnapCarousel)