# 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`**

```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`:

```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.

```tsx
// 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`:

```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:

```tsx
<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:

```css
@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`**

```tsx
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:

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

To:

```tsx
<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:

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

To:

```tsx
<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`**

```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)
```
