-
What We Do
+
What We Do
End-to-end IT services designed for the international community in Japan — all in
English.
@@ -202,7 +202,7 @@ function ValuesSection() {
-
Our Values
+
Our Values
These principles guide how we serve customers, support our community, and advance our
craft every day.
@@ -240,7 +240,7 @@ function CorporateSection() {
-
Corporate Data
+ Corporate Data
diff --git a/apps/portal/src/features/services/components/base/ServicesHero.tsx b/apps/portal/src/features/services/components/base/ServicesHero.tsx
index 26272eac..0d947e46 100644
--- a/apps/portal/src/features/services/components/base/ServicesHero.tsx
+++ b/apps/portal/src/features/services/components/base/ServicesHero.tsx
@@ -58,7 +58,7 @@ export function ServicesHero({
+ {/* Header: name + status */}
+
+
+
+ {subscription.productName}
+
+
+ #{subscription.serviceId}
+
+
+
+
+
+ {/* Price */}
+
+
+ {formatCurrency(subscription.amount, subscription.currency)}
+
+ {cycleLabel && /{cycleLabel} }
+
+
+ {/* Footer: next due + action */}
+
+ {subscription.nextDue && (
+
+
+
+ {new Date(subscription.nextDue).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ )}
+
+ Manage
+
+
+
+
+ );
+}
+
+export function SubscriptionGridCardSkeleton() {
+ return (
+
+ );
+}
diff --git a/apps/portal/src/features/subscriptions/components/index.ts b/apps/portal/src/features/subscriptions/components/index.ts
index 6328ac5a..6a579485 100644
--- a/apps/portal/src/features/subscriptions/components/index.ts
+++ b/apps/portal/src/features/subscriptions/components/index.ts
@@ -1,2 +1,3 @@
export * from "./SubscriptionStatusBadge";
export * from "./CancellationFlow";
+export { SubscriptionGridCard, SubscriptionGridCardSkeleton } from "./SubscriptionGridCard";
diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
index 9b4b94fa..25302b68 100644
--- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
+++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
@@ -2,17 +2,19 @@
import { useState, useMemo } from "react";
import { Button } from "@/components/atoms/button";
-import { ErrorBoundary, SummaryStats } from "@/components/molecules";
-import type { StatItem } from "@/components/molecules/SummaryStats";
+import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
+import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
+import { ErrorBoundary } from "@/components/molecules";
import { PageLayout } from "@/components/templates/PageLayout";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
-import {
- SubscriptionStatsCardsSkeleton,
- SubscriptionTableSkeleton,
-} from "@/components/atoms/loading-skeleton";
+import { SubscriptionTableSkeleton } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
-import { Server, CheckCircle, XCircle } from "lucide-react";
+import {
+ SubscriptionGridCard,
+ SubscriptionGridCardSkeleton,
+} from "@/features/subscriptions/components/SubscriptionGridCard";
+import { Server, CheckCircle, XCircle, TrendingUp } from "lucide-react";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import {
SUBSCRIPTION_STATUS,
@@ -22,37 +24,83 @@ import {
const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
-function SubscriptionStatsCards({
+function SubscriptionMetrics({
stats,
}: {
stats: { active: number; completed: number; cancelled: number };
}) {
- const items: StatItem[] = [
- {
- icon: ,
- label: "Active",
- value: stats.active,
- tone: "success",
- },
- {
- icon: ,
- label: "Completed",
- value: stats.completed,
- tone: "primary",
- },
- {
- icon: ,
- label: "Cancelled",
- value: stats.cancelled,
- tone: "muted",
- },
- ];
- return ;
+ return (
+
+ }
+ label="Active"
+ value={stats.active}
+ tone="success"
+ />
+ }
+ label="Total"
+ value={stats.active + stats.completed + stats.cancelled}
+ tone="primary"
+ />
+ }
+ label="Completed"
+ value={stats.completed}
+ tone="info"
+ />
+ }
+ label="Cancelled"
+ value={stats.cancelled}
+ tone="neutral"
+ />
+
+ );
+}
+
+function SubscriptionMetricsSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function SubscriptionGrid({
+ subscriptions,
+ loading,
+}: {
+ subscriptions: Subscription[];
+ loading: boolean;
+}) {
+ if (subscriptions.length === 0 && !loading) {
+ return (
+
+
+
No subscriptions found
+
No active subscriptions at this time
+
+ );
+ }
+
+ return (
+
+ {subscriptions.map(sub => (
+
+ ))}
+ {loading &&
+ Array.from({ length: 3 }).map((_, i) => )}
+
+ );
}
export function SubscriptionsListContainer() {
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
+ const [viewMode, setViewMode] = useState("grid");
const {
data: subscriptionData,
@@ -85,6 +133,17 @@ export function SubscriptionsListContainer() {
[]
);
+ const activeFilters = useMemo(() => {
+ const filters: { label: string; onRemove: () => void }[] = [];
+ if (statusFilter !== "all") {
+ filters.push({ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("all") });
+ }
+ if (searchTerm) {
+ filters.push({ label: `Search: ${searchTerm}`, onRemove: () => setSearchTerm("") });
+ }
+ return filters;
+ }, [statusFilter, searchTerm]);
+
if (showLoading || error) {
return (
-
+
@@ -114,9 +173,10 @@ export function SubscriptionsListContainer() {
}
>
- {stats && }
-
-
+ {stats &&
}
+
+
+
setStatusFilter(value as SubscriptionStatus | "all")}
filterOptions={statusFilterOptions}
filterLabel="Filter by status"
- />
+ activeFilters={activeFilters}
+ >
+
+
+
+
+
+ {viewMode === "grid" ? (
+
+ ) : (
+
+ )}
-
diff --git a/apps/portal/src/styles/tokens.css b/apps/portal/src/styles/tokens.css
index 046f0f07..e2f128df 100644
--- a/apps/portal/src/styles/tokens.css
+++ b/apps/portal/src/styles/tokens.css
@@ -54,7 +54,7 @@
--cp-page-padding: var(--cp-page-padding-x);
/* Sidebar */
- --cp-sidebar-width: 16rem; /* 256px */
+ --cp-sidebar-width: 13.75rem; /* 220px */
--cp-sidebar-width-collapsed: 4rem; /* 64px */
/* ============= TYPOGRAPHY ============= */
diff --git a/apps/portal/src/styles/utilities.css b/apps/portal/src/styles/utilities.css
index c0502a23..0e7b5bff 100644
--- a/apps/portal/src/styles/utilities.css
+++ b/apps/portal/src/styles/utilities.css
@@ -242,6 +242,34 @@
animation-delay: calc(var(--cp-stagger-5) + 50ms);
}
+ /* Card grid stagger - faster delay for dense grids */
+ .cp-stagger-grid > * {
+ opacity: 0;
+ animation: cp-fade-up var(--cp-duration-normal) var(--cp-ease-out) forwards;
+ }
+
+ .cp-stagger-grid > *:nth-child(1) {
+ animation-delay: 0ms;
+ }
+ .cp-stagger-grid > *:nth-child(2) {
+ animation-delay: 30ms;
+ }
+ .cp-stagger-grid > *:nth-child(3) {
+ animation-delay: 60ms;
+ }
+ .cp-stagger-grid > *:nth-child(4) {
+ animation-delay: 90ms;
+ }
+ .cp-stagger-grid > *:nth-child(5) {
+ animation-delay: 120ms;
+ }
+ .cp-stagger-grid > *:nth-child(6) {
+ animation-delay: 150ms;
+ }
+ .cp-stagger-grid > *:nth-child(n + 7) {
+ animation-delay: 180ms;
+ }
+
/* ===== TAB SLIDE TRANSITIONS ===== */
.cp-slide-fade-left {
animation: cp-slide-fade-left 300ms var(--cp-ease-out) forwards;
@@ -672,6 +700,7 @@
.cp-animate-scale-in,
.cp-animate-slide-left,
.cp-stagger-children > *,
+ .cp-stagger-grid > *,
.cp-card-hover-lift,
.cp-slide-fade-left,
.cp-slide-fade-right,
diff --git a/docs/plans/2026-03-05-parallax-pinned-chapters-design.md b/docs/plans/2026-03-05-parallax-pinned-chapters-design.md
new file mode 100644
index 00000000..c412b274
--- /dev/null
+++ b/docs/plans/2026-03-05-parallax-pinned-chapters-design.md
@@ -0,0 +1,162 @@
+# Parallax Pinned Chapters + Snap Carousel Design
+
+**Date:** 2026-03-05
+**Status:** Approved
+
+## Overview
+
+Redesign the landing page scroll experience using a "chapter" model. Sections are grouped into 4 chapters that pin (sticky) in place as the user scrolls. Each subsequent chapter slides up and covers the previous one, creating a layered card-stack effect. The services carousel is rebuilt with CSS scroll-snap for native horizontal snapping.
+
+## Chapter Structure
+
+| Chapter | Sections | Purpose |
+| ------- | -------------------------- | --------------- |
+| 1 | HeroSection + TrustStrip | "Who we are" |
+| 2 | ServicesCarousel | "What we offer" |
+| 3 | WhyUsSection + CTABanner | "Why choose us" |
+| 4 | SupportDownloads + Contact | "Get in touch" |
+
+## Scroll Behavior
+
+### Pinning Mechanism
+
+- Each chapter wrapper uses `position: sticky; top: 0`
+- Chapters stack with increasing `z-index` (1, 2, 3, 4)
+- Each chapter has a solid background so it fully covers the chapter behind it
+- A subtle `box-shadow` on the top edge of each chapter creates the "sliding over" depth illusion
+- The outer container uses `scroll-snap-type: y proximity` for soft vertical snap (helps chapters land cleanly but does not fight free scrolling)
+
+### Chapter Details
+
+**Chapter 1: Hero + TrustStrip**
+
+- `min-height: 100dvh` to fill the viewport
+- Hero fills most of the space, TrustStrip anchored at the bottom
+- Pins in place while Chapter 2 slides up over it
+- Existing gradient background + dot pattern preserved
+
+**Chapter 2: ServicesCarousel**
+
+- Natural content height (not forced to viewport height)
+- Pins in place while Chapter 3 slides up
+- Carousel rebuilt with CSS scroll-snap (see Carousel section below)
+
+**Chapter 3: WhyUs + CTABanner**
+
+- Natural content height
+- Pins in place while Chapter 4 slides up
+- WhyUs image gets a subtle parallax speed difference (scrolls slightly slower than text) for added depth
+
+**Chapter 4: SupportDownloads + Contact**
+
+- Normal scroll, no pinning (last chapter, nothing covers it)
+- Existing fade-in animations preserved
+
+## Carousel Rebuild (CSS Scroll-Snap)
+
+The current carousel uses absolute positioning with JS-driven transforms and offset calculations. This will be replaced with a native CSS scroll-snap approach.
+
+### New approach
+
+- Horizontal scroll container with `scroll-snap-type: x mandatory`
+- Each card is a snap point with `scroll-snap-align: center`
+- Cards are laid out in a flex row, each taking full width of the visible area
+- Native touch/swipe works out of the box
+- Dot indicators sync with scroll position via `IntersectionObserver` or `scrollLeft` calculation
+- Arrow buttons use `scrollBy()` with `behavior: 'smooth'`
+- Auto-play uses `scrollBy()` and pauses on user interaction (touch, hover, focus)
+- Personal/Business tab toggle resets scroll position to 0
+
+### Benefits over current approach
+
+- No absolute positioning or transform math
+- Touch/swipe is native and performant
+- Reduced JS complexity
+- Better accessibility (native scroll semantics)
+- Respects `prefers-reduced-motion` automatically
+
+## Technical Implementation
+
+### New component: ChapterWrapper
+
+A reusable wrapper that applies sticky positioning:
+
+```tsx
+interface ChapterProps {
+ children: React.ReactNode;
+ zIndex: number;
+ className?: string;
+}
+
+function Chapter({ children, zIndex, className }: ChapterProps) {
+ return (
+
+ );
+}
+```
+
+### Updated PublicLandingView structure
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {" "}
+ {/* No sticky - last chapter */}
+
+
+
+
+```
+
+### Shadow/depth effect
+
+Each Chapter (except Chapter 1) gets a top shadow to enhance the "sliding over" illusion:
+
+```css
+.chapter-overlay {
+ box-shadow: 0 -8px 30px -10px rgba(0, 0, 0, 0.1);
+}
+```
+
+## Mobile Considerations
+
+- Use `100dvh` (dynamic viewport height) instead of `100vh` to avoid iOS address bar issues
+- Carousel CSS scroll-snap is touch-native and works well on mobile
+- Sticky positioning works on mobile browsers (iOS Safari, Chrome Android)
+- If performance is poor on low-end devices, sticky can be disabled via a CSS class
+
+## Accessibility
+
+- `prefers-reduced-motion: reduce` disables sticky pinning behavior (falls back to normal scroll)
+- Carousel maintains keyboard navigation (arrow keys, tab through cards)
+- ARIA attributes preserved: `role="region"`, `aria-roledescription="carousel"`, slide labels
+- Scroll-snap does not interfere with screen readers
+
+## Files to Modify
+
+| File | Change |
+| ------------------------------------------------------- | --------------------------------------- |
+| `features/landing-page/views/PublicLandingView.tsx` | Wrap sections in Chapter components |
+| `features/landing-page/components/ServicesCarousel.tsx` | Rebuild with CSS scroll-snap |
+| `features/landing-page/components/HeroSection.tsx` | Adjust to fill chapter space (flex-1) |
+| `features/landing-page/components/TrustStrip.tsx` | Adjust to anchor at bottom of Chapter 1 |
+| `features/landing-page/hooks/useInfiniteCarousel.ts` | Replace with scroll-snap hook or remove |
+| `features/landing-page/components/index.ts` | Export new Chapter component |
+| New: `features/landing-page/components/Chapter.tsx` | Chapter wrapper component |
+| New: `features/landing-page/hooks/useSnapCarousel.ts` | Hook for scroll-snap carousel state |
diff --git a/docs/plans/2026-03-05-parallax-pinned-chapters-plan.md b/docs/plans/2026-03-05-parallax-pinned-chapters-plan.md
new file mode 100644
index 00000000..69e24e22
--- /dev/null
+++ b/docs/plans/2026-03-05-parallax-pinned-chapters-plan.md
@@ -0,0 +1,619 @@
+# 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 (
+
+ );
+}
+```
+
+**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
(null);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ // Auto-play pause tracking
+ const pausedRef = useRef(false);
+ const pauseTimerRef = useRef>();
+
+ 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
+
+ {cards.map((card, i) => (
+
+ ))}
+
+```
+
+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 `` is the semantic wrapper now)
+- Keep the gradient background, dot pattern, and all content unchanged
+
+Change:
+
+```tsx
+
+```
+
+To:
+
+```tsx
+
+```
+
+**Step 2: Update TrustStrip**
+
+- Remove `full-bleed` from the section
+- Change to a `div` (Chapter is the semantic `
`)
+- Keep all content and animation unchanged
+
+Change:
+
+```tsx
+
+```
+
+To:
+
+```tsx
+
+```
+
+**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 `
` to ``
+- Keep all content unchanged
+
+**Step 2: Update CTABanner**
+
+- Remove `full-bleed` from the section
+- Change `
` to ``
+- 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 `
` to `` (or keep as `
` since Chapter 4 is a plain ``)
+
+Actually, Chapter 4 is NOT sticky (it's the last chapter, just a plain wrapper). So ContactSection and SupportDownloadsSection can remain as `
` 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 (
+
+ {/* Chapter 1: Who we are */}
+
+
+
+
+
+ {/* Chapter 2: What we offer */}
+
+
+
+
+ {/* Chapter 3: Why choose us */}
+
+
+
+
+
+ {/* Chapter 4: Get in touch (no sticky, last chapter) */}
+
+
+
+
+
+ {/* Sticky Mobile CTA */}
+ {showStickyCTA && (
+
+ }
+ className="w-full shadow-lg"
+ >
+ Find Your Plan
+
+
+ )}
+
+ );
+}
+```
+
+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)
+```
diff --git a/docs/plans/2026-03-05-portal-ui-overhaul-plan.md b/docs/plans/2026-03-05-portal-ui-overhaul-plan.md
new file mode 100644
index 00000000..74377fbd
--- /dev/null
+++ b/docs/plans/2026-03-05-portal-ui-overhaul-plan.md
@@ -0,0 +1,1690 @@
+# Portal UI Overhaul Implementation Plan
+
+> **For Claude:**
+
+**Goal:** Overhaul the signed-in portal experience with a dark navy sidebar, updated typography, warmer color palette, new shared components, redesigned header with command palette, and an enriched subscriptions page with grid view.
+
+**Architecture:** Pure frontend changes across the portal app. Update design tokens (CSS variables), font imports, shared components (atoms/molecules), shell components (header, sidebar), and feature views (subscriptions, dashboard). No BFF or domain changes needed.
+
+**Tech Stack:** Next.js 15, React 19, Tailwind CSS v4, CVA (class-variance-authority), HeroIcons, Lucide React, next/font/google
+
+---
+
+## Task 1: Typography — Add DM Sans & JetBrains Mono Fonts
+
+**Files:**
+
+- Modify: `apps/portal/src/app/layout.tsx`
+- Modify: `apps/portal/src/app/globals.css` (lines 20-21, the `--font-sans` and `--font-display` variables)
+
+**Step 1: Add DM Sans and JetBrains Mono font imports in layout.tsx**
+
+Add two new font imports alongside the existing Plus Jakarta Sans:
+
+```tsx
+import { Plus_Jakarta_Sans, DM_Sans, JetBrains_Mono } from "next/font/google";
+
+const plusJakartaSans = Plus_Jakarta_Sans({
+ subsets: ["latin"],
+ variable: "--font-jakarta",
+ display: "swap",
+});
+
+const dmSans = DM_Sans({
+ subsets: ["latin"],
+ variable: "--font-dm-sans",
+ display: "swap",
+});
+
+const jetbrainsMono = JetBrains_Mono({
+ subsets: ["latin"],
+ variable: "--font-jetbrains",
+ display: "swap",
+});
+```
+
+Update the body className to include all three font variables:
+
+```tsx
+
+```
+
+**Step 2: Update CSS font-family variables in globals.css**
+
+In the `:root` block (around lines 20-21), change:
+
+```css
+/* Typography */
+--font-sans: var(--font-dm-sans, system-ui, sans-serif);
+--font-display: var(--font-jakarta, var(--font-sans));
+--font-mono: var(--font-jetbrains, ui-monospace, monospace);
+```
+
+**Step 3: Add font-mono to Tailwind theme mapping**
+
+In the `@theme` block of globals.css (around line 228), add after `--font-family-display`:
+
+```css
+--font-family-mono: var(--font-mono);
+```
+
+**Step 4: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS (no type errors from font imports)
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 5: Commit**
+
+```
+feat(portal): update typography system with DM Sans and JetBrains Mono
+
+Replace Geist Sans with DM Sans for body text and add JetBrains Mono
+for monospace/data display. Plus Jakarta Sans remains for display headings.
+```
+
+---
+
+## Task 2: Color System — Dark Navy Sidebar & Warmer Neutrals
+
+**Files:**
+
+- Modify: `apps/portal/src/app/globals.css` (`:root` and `.dark` blocks)
+
+**Step 1: Update sidebar colors to dark navy**
+
+In the `:root` block, replace the sidebar color variables (around lines 82-86):
+
+```css
+/* Sidebar - Dark Navy */
+--sidebar: oklch(0.18 0.03 250);
+--sidebar-foreground: oklch(1 0 0);
+--sidebar-border: oklch(0.25 0.04 250);
+--sidebar-active: oklch(0.99 0 0 / 0.12);
+--sidebar-accent: var(--primary);
+```
+
+In the `.dark` block, update sidebar dark mode (around lines 195-197):
+
+```css
+/* Sidebar - Dark Navy for dark mode */
+--sidebar: oklch(0.13 0.025 250);
+--sidebar-border: oklch(0.22 0.03 250);
+```
+
+**Step 2: Add sidebar-accent to Tailwind theme mapping**
+
+In the `@theme` block, after `--color-sidebar-active` (around line 290), add:
+
+```css
+--color-sidebar-accent: var(--sidebar-accent);
+```
+
+**Step 3: Warm up neutral surface colors**
+
+In the `:root` block, adjust these variables to add slight warmth:
+
+```css
+/* Core Surfaces - slightly warmer */
+--muted: oklch(0.965 0.006 70);
+--muted-foreground: oklch(0.46 0.01 70);
+
+/* Chrome - slightly warmer borders */
+--border: oklch(0.925 0.006 70);
+--input: oklch(0.955 0.005 70);
+```
+
+In the `.dark` block, update the warm equivalents:
+
+```css
+--muted: oklch(0.25 0.008 70);
+--muted-foreground: oklch(0.72 0.01 70);
+--border: oklch(0.3 0.012 70);
+--input: oklch(0.33 0.01 70);
+```
+
+**Step 4: Update the main background for a subtle warm tint**
+
+In `:root`:
+
+```css
+--background: oklch(0.993 0.002 70);
+--surface-elevated: oklch(0.998 0.001 70);
+--surface-sunken: oklch(0.975 0.004 70);
+```
+
+In `.dark`:
+
+```css
+--background: oklch(0.12 0.012 250);
+--surface-elevated: oklch(0.18 0.012 250);
+--surface-sunken: oklch(0.1 0.012 250);
+```
+
+**Step 5: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 6: Commit**
+
+```
+style(portal): dark navy sidebar and warmer neutral palette
+
+Switch sidebar from deep purple to dark navy for brand continuity.
+Warm up neutral surfaces with subtle warmth (hue 70) to reduce sterility.
+```
+
+---
+
+## Task 3: Sidebar Component — Navy Styling & Section Labels
+
+**Files:**
+
+- Modify: `apps/portal/src/components/organisms/AppShell/Sidebar.tsx`
+- Modify: `apps/portal/src/components/organisms/AppShell/navigation.ts`
+
+**Step 1: Update sidebar header branding area**
+
+In `Sidebar.tsx`, update the header section (around lines 67-77). Replace the logo background and add a subtle gradient to the sidebar header:
+
+```tsx
+
+
+
+
+
+
+
Assist Solutions
+
Customer Portal
+
+
+
+```
+
+**Step 2: Add section labels to navigation**
+
+In `navigation.ts`, add a `section` property to NavigationItem interface:
+
+```ts
+export interface NavigationItem {
+ name: string;
+ href?: string;
+ icon: ComponentType>;
+ children?: NavigationChild[];
+ isLogout?: boolean;
+ section?: string;
+}
+```
+
+Add section labels to the `baseNavigation` array. Add `section: "Overview"` to Dashboard, `section: "Account"` to Billing (the first item after Dashboard/Orders), and `section: ""` to the logout item. This creates visual groupings.
+
+```ts
+export const baseNavigation: NavigationItem[] = [
+ { name: "Dashboard", href: "/account", icon: HomeIcon, section: "Overview" },
+ { name: "Orders", href: "/account/orders", icon: ClipboardDocumentListIcon },
+ {
+ name: "Billing",
+ icon: CreditCardIcon,
+ section: "Account",
+ children: [
+ { name: "Invoices", href: "/account/billing/invoices" },
+ { name: "Payment Methods", href: "/account/billing/payment-methods" },
+ ],
+ },
+ // ... rest remains the same
+```
+
+**Step 3: Render section labels in Sidebar.tsx**
+
+In the `Sidebar` component's nav rendering, add section label support. Replace the `navigation.map` block:
+
+```tsx
+
+ {navigation.map((item, index) => (
+
+ {item.section && (
+
+
+ {item.section}
+
+
+ )}
+
+
+ ))}
+
+```
+
+**Step 4: Update active state to use primary blue accent instead of white**
+
+In `Sidebar.tsx`, update the shared styling constants (around lines 12-15):
+
+```tsx
+const navItemBaseClass =
+ "group w-full flex items-center px-3 py-2 text-[13px] font-medium rounded-lg transition-all duration-200 relative focus:outline-none focus:ring-2 focus:ring-white/20";
+const activeClass = "text-white bg-white/[0.08] shadow-sm";
+const inactiveClass = "text-white/60 hover:text-white/90 hover:bg-white/[0.06]";
+```
+
+Update `ActiveIndicator` to use primary blue:
+
+```tsx
+function ActiveIndicator({ small = false }: { small?: boolean }) {
+ const size = small ? "w-0.5 h-3.5" : "w-[3px] h-5";
+ return (
+
+ );
+}
+```
+
+Update `NavIcon` to use subtler styling:
+
+```tsx
+function NavIcon({
+ icon: Icon,
+ isActive,
+ variant = "default",
+}: {
+ icon: ComponentType>;
+ isActive: boolean;
+ variant?: "default" | "logout";
+}) {
+ if (variant === "logout") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+```
+
+**Step 5: Update child navigation items styling**
+
+In `ExpandableNavItem`, update the child items container and styling (around line 144):
+
+```tsx
+
+ {item.children?.map((child: NavigationChild) => {
+ const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
+ return (
+
child.href && void router.prefetch(child.href)}
+ className={`group flex items-center px-2.5 py-1.5 text-[13px] rounded-md transition-all duration-200 relative ${
+ isChildActive
+ ? "text-white bg-white/[0.08] font-medium"
+ : "text-white/50 hover:text-white/80 hover:bg-white/[0.04] font-normal"
+ }`}
+ title={child.tooltip || child.name}
+ aria-current={isChildActive ? "page" : undefined}
+ >
+ {isChildActive &&
}
+
{child.name}
+
+ );
+ })}
+
+```
+
+Remove the dot indicator before child names (cleaner without it).
+
+**Step 6: Update the logout button styling**
+
+```tsx
+function LogoutNavItem({ item, onLogout }: { item: NavigationItem; onLogout: () => void }) {
+ return (
+
+
+
+ {item.name}
+
+
+ );
+}
+```
+
+**Step 7: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 8: Commit**
+
+```
+style(portal): overhaul sidebar with navy theme and section labels
+
+- Switch from purple to dark navy color scheme
+- Add section labels (Overview, Account) for visual grouping
+- Use primary blue for active indicator instead of white
+- Reduce visual weight of icons and text for cleaner look
+- Separate logout with border divider
+```
+
+---
+
+## Task 4: Header — Redesign with Search & Improved Profile
+
+**Files:**
+
+- Modify: `apps/portal/src/components/organisms/AppShell/Header.tsx`
+- Modify: `apps/portal/src/components/organisms/AppShell/AppShell.tsx` (pass breadcrumbs to header)
+
+**Step 1: Redesign the Header component**
+
+Replace the entire Header component in `Header.tsx`:
+
+```tsx
+"use client";
+
+import Link from "next/link";
+import { memo } from "react";
+import {
+ Bars3Icon,
+ QuestionMarkCircleIcon,
+ MagnifyingGlassIcon,
+} from "@heroicons/react/24/outline";
+import { NotificationBell } from "@/features/notifications";
+
+interface UserInfo {
+ firstName?: string | null;
+ lastName?: string | null;
+ email?: string | null;
+}
+
+function getDisplayName(user: UserInfo | null, profileReady: boolean): string {
+ const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(" ");
+ const emailPrefix = user?.email?.split("@")[0];
+ if (profileReady) {
+ return fullName || emailPrefix || "Account";
+ }
+ return emailPrefix || "Account";
+}
+
+function getInitials(user: UserInfo | null, profileReady: boolean, displayName: string): string {
+ if (profileReady && user?.firstName && user?.lastName) {
+ return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
+ }
+ return displayName.slice(0, 2).toUpperCase();
+}
+
+interface HeaderProps {
+ onMenuClick: () => void;
+ user: UserInfo | null;
+ profileReady: boolean;
+}
+
+export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) {
+ const displayName = getDisplayName(user, profileReady);
+ const initials = getInitials(user, profileReady, displayName);
+
+ return (
+
+
+ {/* Mobile menu button */}
+
+
+
+
+ {/* Search trigger */}
+
+
+ Search...
+
+ ⌘ K
+
+
+
+
+
+ {/* Right side actions */}
+
+ {/* Notification bell */}
+
+
+ {/* Help link */}
+
+
+
+
+ {/* Divider */}
+
+
+ {/* Profile link */}
+
+
+ {initials}
+
+
{displayName}
+
+
+
+
+ );
+});
+```
+
+Key changes:
+
+- Reduced header height from 64px to 56px (h-14)
+- Added search trigger button with keyboard shortcut hint
+- Added visual divider between actions and profile
+- Tightened spacing and made icons slightly smaller
+- More transparent glass effect on the header
+
+**Step 2: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 3: Commit**
+
+```
+style(portal): redesign header with search bar and tighter layout
+
+- Add search trigger button with keyboard shortcut hint (visual only for now)
+- Reduce header height from 64px to 56px
+- Add divider between action icons and profile
+- Increase glass morphism transparency
+- Tighten icon sizes and spacing
+```
+
+---
+
+## Task 5: New Shared Component — StatusIndicator
+
+**Files:**
+
+- Create: `apps/portal/src/components/atoms/status-indicator.tsx`
+- Modify: `apps/portal/src/components/atoms/index.ts`
+
+**Step 1: Create the StatusIndicator component**
+
+```tsx
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/shared/utils";
+
+const statusIndicatorVariants = cva("inline-flex items-center gap-1.5", {
+ variants: {
+ size: {
+ sm: "text-xs",
+ md: "text-sm",
+ lg: "text-base",
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ },
+});
+
+const dotVariants = cva("rounded-full flex-shrink-0", {
+ variants: {
+ status: {
+ active: "bg-success",
+ warning: "bg-warning",
+ error: "bg-danger",
+ inactive: "bg-muted-foreground/30",
+ pending: "bg-info",
+ },
+ size: {
+ sm: "h-1.5 w-1.5",
+ md: "h-2 w-2",
+ lg: "h-2.5 w-2.5",
+ },
+ pulse: {
+ true: "animate-pulse",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ status: "active",
+ size: "md",
+ pulse: false,
+ },
+});
+
+export type StatusIndicatorStatus = "active" | "warning" | "error" | "inactive" | "pending";
+
+interface StatusIndicatorProps extends VariantProps {
+ status: StatusIndicatorStatus;
+ label?: string;
+ pulse?: boolean;
+ className?: string;
+}
+
+export function StatusIndicator({ status, label, size, pulse, className }: StatusIndicatorProps) {
+ return (
+
+
+ {label && {label} }
+
+ );
+}
+```
+
+**Step 2: Export from atoms barrel**
+
+In `apps/portal/src/components/atoms/index.ts`, add:
+
+```ts
+export { StatusIndicator, type StatusIndicatorStatus } from "./status-indicator";
+```
+
+**Step 3: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+**Step 4: Commit**
+
+```
+feat(portal): add StatusIndicator shared component
+
+Consistent dot + label component for status display across subscriptions,
+orders, and support views. Supports 5 status variants with optional pulse.
+```
+
+---
+
+## Task 6: New Shared Component — MetricCard
+
+**Files:**
+
+- Create: `apps/portal/src/components/molecules/MetricCard/MetricCard.tsx`
+- Create: `apps/portal/src/components/molecules/MetricCard/index.ts`
+- Modify: `apps/portal/src/components/molecules/index.ts`
+
+**Step 1: Create the MetricCard component**
+
+```tsx
+import type { ReactNode } from "react";
+import { cn } from "@/shared/utils";
+
+type MetricTone = "primary" | "success" | "warning" | "danger" | "info" | "neutral";
+
+const toneStyles: Record = {
+ primary: { icon: "text-primary bg-primary/10", accent: "text-primary" },
+ success: { icon: "text-success bg-success/10", accent: "text-success" },
+ warning: { icon: "text-warning bg-warning/10", accent: "text-warning" },
+ danger: { icon: "text-danger bg-danger/10", accent: "text-danger" },
+ info: { icon: "text-info bg-info/10", accent: "text-info" },
+ neutral: { icon: "text-muted-foreground bg-muted", accent: "text-muted-foreground" },
+};
+
+export interface MetricCardProps {
+ icon?: ReactNode;
+ label: string;
+ value: string | number;
+ subtitle?: string;
+ tone?: MetricTone;
+ trend?: { value: string; positive?: boolean };
+ className?: string;
+}
+
+export function MetricCard({
+ icon,
+ label,
+ value,
+ subtitle,
+ tone = "primary",
+ trend,
+ className,
+}: MetricCardProps) {
+ const styles = toneStyles[tone];
+
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
{label}
+
+
+ {value}
+
+ {trend && (
+
+ {trend.value}
+
+ )}
+
+ {subtitle &&
{subtitle}
}
+
+
+ );
+}
+
+export function MetricCardSkeleton({ className }: { className?: string }) {
+ return (
+
+ );
+}
+```
+
+**Step 2: Create barrel file**
+
+`apps/portal/src/components/molecules/MetricCard/index.ts`:
+
+```ts
+export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard";
+```
+
+**Step 3: Export from molecules barrel**
+
+In `apps/portal/src/components/molecules/index.ts`, add:
+
+```ts
+export { MetricCard, MetricCardSkeleton, type MetricCardProps } from "./MetricCard";
+```
+
+**Step 4: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+**Step 5: Commit**
+
+```
+feat(portal): add MetricCard shared component
+
+Reusable metric display with icon, value, trend indicator, and tone-based
+styling. Used across dashboard, subscriptions, and billing pages.
+```
+
+---
+
+## Task 7: New Shared Component — ViewToggle
+
+**Files:**
+
+- Create: `apps/portal/src/components/atoms/view-toggle.tsx`
+- Modify: `apps/portal/src/components/atoms/index.ts`
+
+**Step 1: Create the ViewToggle component**
+
+```tsx
+"use client";
+
+import { Squares2X2Icon, ListBulletIcon } from "@heroicons/react/24/outline";
+import { cn } from "@/shared/utils";
+
+export type ViewMode = "grid" | "list";
+
+interface ViewToggleProps {
+ value: ViewMode;
+ onChange: (mode: ViewMode) => void;
+ className?: string;
+}
+
+export function ViewToggle({ value, onChange, className }: ViewToggleProps) {
+ return (
+
+ onChange("grid")}
+ className={cn(
+ "inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
+ value === "grid"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ )}
+ aria-label="Grid view"
+ aria-pressed={value === "grid"}
+ >
+
+
+ onChange("list")}
+ className={cn(
+ "inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
+ value === "list"
+ ? "bg-background text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground"
+ )}
+ aria-label="List view"
+ aria-pressed={value === "list"}
+ >
+
+
+
+ );
+}
+```
+
+**Step 2: Export from atoms barrel**
+
+In `apps/portal/src/components/atoms/index.ts`, add:
+
+```ts
+export { ViewToggle, type ViewMode } from "./view-toggle";
+```
+
+**Step 3: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+**Step 4: Commit**
+
+```
+feat(portal): add ViewToggle shared component
+
+Grid/list toggle button pair for collection views. Used in subscriptions
+and any future list pages.
+```
+
+---
+
+## Task 8: Enhanced FilterBar Component
+
+**Files:**
+
+- Modify: `apps/portal/src/components/molecules/SearchFilterBar/SearchFilterBar.tsx`
+
+**Step 1: Enhance the SearchFilterBar with active filter pills**
+
+Read the current file first, then update it. Keep the existing props interface but add support for additional filter display and an optional ViewToggle integration:
+
+```tsx
+"use client";
+
+import { MagnifyingGlassIcon, FunnelIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import { cn } from "@/shared/utils";
+import type { ReactNode } from "react";
+
+export interface FilterOption {
+ value: string;
+ label: string;
+}
+
+export interface SearchFilterBarProps {
+ searchValue: string;
+ onSearchChange: (value: string) => void;
+ searchPlaceholder?: string;
+ filterValue?: string;
+ onFilterChange?: (value: string) => void;
+ filterOptions?: FilterOption[];
+ filterLabel?: string;
+ activeFilters?: { label: string; onRemove: () => void }[];
+ children?: ReactNode;
+}
+
+export function SearchFilterBar({
+ searchValue,
+ onSearchChange,
+ searchPlaceholder = "Search...",
+ filterValue,
+ onFilterChange,
+ filterOptions,
+ filterLabel = "Filter",
+ activeFilters,
+ children,
+}: SearchFilterBarProps) {
+ return (
+
+
+ {/* Search */}
+
+
+ onSearchChange(e.target.value)}
+ placeholder={searchPlaceholder}
+ className="w-full h-9 pl-9 pr-3 rounded-lg border border-border/60 bg-background text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30 transition-all duration-200"
+ />
+
+
+
+ {/* Filter select */}
+ {filterOptions && onFilterChange && (
+
+ onFilterChange(e.target.value)}
+ className="h-9 pl-3 pr-8 rounded-lg border border-border/60 bg-background text-sm text-foreground appearance-none focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30 transition-all duration-200"
+ aria-label={filterLabel}
+ >
+ {filterOptions.map(opt => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ )}
+
+ {/* Custom actions (ViewToggle, etc.) */}
+ {children}
+
+
+
+ {/* Active filter pills */}
+ {activeFilters && activeFilters.length > 0 && (
+
+ {activeFilters.map(filter => (
+
+ {filter.label}
+
+
+ ))}
+
+ )}
+
+ );
+}
+```
+
+**Step 2: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 3: Commit**
+
+```
+style(portal): enhance SearchFilterBar with active filter pills
+
+- Add active filter pill display with remove buttons
+- Accept children for custom actions (ViewToggle integration)
+- Tighten visual styling and reduce border weight
+```
+
+---
+
+## Task 9: Subscription Grid Card Component
+
+**Files:**
+
+- Create: `apps/portal/src/features/subscriptions/components/SubscriptionGridCard.tsx`
+- Modify: `apps/portal/src/features/subscriptions/components/index.ts`
+
+**Step 1: Create the grid card component**
+
+```tsx
+"use client";
+
+import Link from "next/link";
+import { CalendarDaysIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
+import { StatusIndicator, type StatusIndicatorStatus } from "@/components/atoms";
+import { cn } from "@/shared/utils";
+import { useFormatCurrency } from "@/shared/hooks";
+import type { Subscription, SubscriptionStatus } from "@customer-portal/domain/subscriptions";
+import { SUBSCRIPTION_STATUS, SUBSCRIPTION_CYCLE } from "@customer-portal/domain/subscriptions";
+import { getBillingCycleLabel } from "@/features/subscriptions/utils/status-presenters";
+
+function mapSubscriptionStatus(status: SubscriptionStatus): StatusIndicatorStatus {
+ switch (status) {
+ case SUBSCRIPTION_STATUS.Active:
+ return "active";
+ case SUBSCRIPTION_STATUS.Pending:
+ return "pending";
+ case SUBSCRIPTION_STATUS.Suspended:
+ case SUBSCRIPTION_STATUS.Cancelled:
+ return "warning";
+ case SUBSCRIPTION_STATUS.Terminated:
+ return "error";
+ default:
+ return "inactive";
+ }
+}
+
+interface SubscriptionGridCardProps {
+ subscription: Subscription;
+ className?: string;
+}
+
+export function SubscriptionGridCard({ subscription, className }: SubscriptionGridCardProps) {
+ const formatCurrency = useFormatCurrency();
+ const statusIndicator = mapSubscriptionStatus(subscription.status);
+ const cycleLabel = getBillingCycleLabel(subscription.cycle);
+
+ return (
+
+ {/* Header: name + status */}
+
+
+
+ {subscription.productName}
+
+
+ #{subscription.serviceId}
+
+
+
+
+
+ {/* Price */}
+
+
+ {formatCurrency(subscription.amount, subscription.currency)}
+
+ {cycleLabel && /{cycleLabel} }
+
+
+ {/* Footer: next due + action */}
+
+ {subscription.nextDue && (
+
+
+
+ {new Date(subscription.nextDue).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ )}
+
+ Manage
+
+
+
+
+ );
+}
+
+export function SubscriptionGridCardSkeleton() {
+ return (
+
+ );
+}
+```
+
+**Step 2: Export from subscriptions components barrel**
+
+In `apps/portal/src/features/subscriptions/components/index.ts`, add:
+
+```ts
+export { SubscriptionGridCard, SubscriptionGridCardSkeleton } from "./SubscriptionGridCard";
+```
+
+**Step 3: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+**Step 4: Commit**
+
+```
+feat(portal): add SubscriptionGridCard component
+
+Card-based subscription display for grid view with status indicator,
+pricing, next due date, and hover-reveal manage action.
+```
+
+---
+
+## Task 10: Subscriptions Page — Grid View & Enriched Stats
+
+**Files:**
+
+- Modify: `apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx`
+
+**Step 1: Rewrite the SubscriptionsList view**
+
+Replace the full contents of `SubscriptionsList.tsx`:
+
+```tsx
+"use client";
+
+import { useState, useMemo } from "react";
+import { Button } from "@/components/atoms/button";
+import { ViewToggle, type ViewMode } from "@/components/atoms/view-toggle";
+import { MetricCard, MetricCardSkeleton } from "@/components/molecules/MetricCard";
+import { ErrorBoundary } from "@/components/molecules";
+import { PageLayout } from "@/components/templates/PageLayout";
+import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
+import { SubscriptionTableSkeleton } from "@/components/atoms/loading-skeleton";
+import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
+import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
+import {
+ SubscriptionGridCard,
+ SubscriptionGridCardSkeleton,
+} from "@/features/subscriptions/components/SubscriptionGridCard";
+import { Server, CheckCircle, XCircle, TrendingUp } from "lucide-react";
+import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
+import {
+ SUBSCRIPTION_STATUS,
+ type Subscription,
+ type SubscriptionStatus,
+} from "@customer-portal/domain/subscriptions";
+
+const SUBSCRIPTION_STATUS_OPTIONS = Object.values(SUBSCRIPTION_STATUS) as SubscriptionStatus[];
+
+function SubscriptionMetrics({
+ stats,
+}: {
+ stats: { active: number; completed: number; cancelled: number };
+}) {
+ return (
+
+ }
+ label="Active"
+ value={stats.active}
+ tone="success"
+ />
+ }
+ label="Total"
+ value={stats.active + stats.completed + stats.cancelled}
+ tone="primary"
+ />
+ }
+ label="Completed"
+ value={stats.completed}
+ tone="info"
+ />
+ }
+ label="Cancelled"
+ value={stats.cancelled}
+ tone="neutral"
+ />
+
+ );
+}
+
+function SubscriptionMetricsSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function SubscriptionGrid({
+ subscriptions,
+ loading,
+}: {
+ subscriptions: Subscription[];
+ loading: boolean;
+}) {
+ if (subscriptions.length === 0 && !loading) {
+ return (
+
+
+
No subscriptions found
+
No active subscriptions at this time
+
+ );
+ }
+
+ return (
+
+ {subscriptions.map(sub => (
+
+ ))}
+ {loading &&
+ Array.from({ length: 3 }).map((_, i) => )}
+
+ );
+}
+
+export function SubscriptionsListContainer() {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [viewMode, setViewMode] = useState("grid");
+
+ const {
+ data: subscriptionData,
+ error,
+ isFetching,
+ } = useSubscriptions(statusFilter === "all" ? {} : { status: statusFilter });
+ const { data: stats } = useSubscriptionStats();
+ const showLoading = !subscriptionData && !error;
+
+ const subscriptions = useMemo((): Subscription[] => {
+ if (!subscriptionData) return [];
+ if (Array.isArray(subscriptionData)) return subscriptionData;
+ return subscriptionData.subscriptions;
+ }, [subscriptionData]);
+
+ const filteredSubscriptions = useMemo(() => {
+ if (!searchTerm) return subscriptions;
+ return subscriptions.filter(
+ s =>
+ s.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ s.serviceId.toString().includes(searchTerm)
+ );
+ }, [subscriptions, searchTerm]);
+
+ const statusFilterOptions = useMemo(
+ () => [
+ { value: "all" as const, label: "All Status" },
+ ...SUBSCRIPTION_STATUS_OPTIONS.map(status => ({ value: status, label: status })),
+ ],
+ []
+ );
+
+ const activeFilters = useMemo(() => {
+ const filters: { label: string; onRemove: () => void }[] = [];
+ if (statusFilter !== "all") {
+ filters.push({ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("all") });
+ }
+ if (searchTerm) {
+ filters.push({ label: `Search: ${searchTerm}`, onRemove: () => setSearchTerm("") });
+ }
+ return filters;
+ }, [statusFilter, searchTerm]);
+
+ if (showLoading || error) {
+ return (
+ }
+ title="Subscriptions"
+ description="Manage your active subscriptions"
+ >
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+ }
+ title="Subscriptions"
+ description="Manage your active subscriptions"
+ actions={
+
+ Browse Services
+
+ }
+ >
+
+ {stats && }
+
+
+
+ setStatusFilter(value as SubscriptionStatus | "all")}
+ filterOptions={statusFilterOptions}
+ filterLabel="Filter by status"
+ activeFilters={activeFilters.length > 0 ? activeFilters : undefined}
+ >
+
+
+
+
+
+ {viewMode === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+export default SubscriptionsListContainer;
+```
+
+**Step 2: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 3: Commit**
+
+```
+feat(portal): overhaul subscriptions page with grid view and metrics
+
+- Replace stat cards with 4-column MetricCard grid (active, total, completed, cancelled)
+- Add grid/list view toggle with default grid view
+- Grid displays subscription cards with status, price, and next due date
+- Add active filter pills for search and status filters
+- Keep existing table view as list mode option
+```
+
+---
+
+## Task 11: Dashboard — Enhanced Control Center Layout
+
+**Files:**
+
+- Modify: `apps/portal/src/features/dashboard/views/DashboardView.tsx`
+- Modify: `apps/portal/src/features/dashboard/components/QuickStats.tsx`
+
+**Step 1: Update DashboardGreeting for a cleaner look**
+
+In `DashboardView.tsx`, update the `DashboardGreeting` component:
+
+```tsx
+function DashboardGreeting({
+ displayName,
+ taskCount,
+ hasUrgentTask,
+}: {
+ displayName: string;
+ taskCount: number;
+ hasUrgentTask: boolean;
+}) {
+ return (
+
+
+ Welcome back, {displayName}
+
+ {taskCount > 0 ? (
+
+
+ {hasUrgentTask && }
+ {taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
+
+
+ ) : (
+
+ Everything is up to date
+
+ )}
+
+ );
+}
+```
+
+Key changes: Combined greeting into single line, removed "Welcome back" as separate element, tightened spacing.
+
+**Step 2: Update QuickStats to use MetricCard style**
+
+In `QuickStats.tsx`, update `StatItem` to have tighter, more refined styling:
+
+```tsx
+function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText }: StatItemProps) {
+ const styles = toneStyles[tone];
+
+ return (
+
+
+
+
+
+
{label}
+ {value > 0 ? (
+
+ {value}
+
+ ) : (
+
{emptyText || "None"}
+ )}
+
+
+
+ );
+}
+```
+
+**Step 3: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 4: Commit**
+
+```
+style(portal): refine dashboard greeting and quick stats
+
+- Combine greeting into single "Welcome back, Name" heading
+- Tighten QuickStats card styling with reduced padding and font sizes
+- Use font-display for numeric values
+- Lighter hover states and borders
+```
+
+---
+
+## Task 12: Button Variant — Add `subtle` Variant
+
+**Files:**
+
+- Modify: `apps/portal/src/components/atoms/button.tsx`
+
+**Step 1: Add the `subtle` variant to buttonVariants**
+
+In the `variant` object inside `buttonVariants` (around line 12), add after `ghost`:
+
+```ts
+subtle:
+ "bg-muted/50 text-foreground hover:bg-muted border border-transparent hover:border-border/40",
+```
+
+**Step 2: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+**Step 3: Commit**
+
+```
+feat(portal): add subtle button variant
+
+Faint background tint button for secondary actions inside cards. Sits
+between ghost (transparent) and outline (bordered).
+```
+
+---
+
+## Task 13: AppShell Layout — Reduce Sidebar Width & Background Texture
+
+**Files:**
+
+- Modify: `apps/portal/src/components/organisms/AppShell/AppShell.tsx`
+- Modify: `apps/portal/src/styles/tokens.css`
+
+**Step 1: Reduce sidebar width from 240px to 220px**
+
+In `AppShell.tsx`, update the desktop sidebar container (around line 202):
+
+```tsx
+
+```
+
+In `tokens.css`, update the sidebar width variable (line 57):
+
+```css
+--cp-sidebar-width: 13.75rem; /* 220px */
+```
+
+**Step 2: Add a subtle background pattern to the main content area**
+
+In `AppShell.tsx`, update the main content wrapper (around line 213):
+
+```tsx
+
+```
+
+No additional texture needed - the warmer neutrals from Task 2 already provide enough visual interest. Keep it clean.
+
+**Step 3: Verify**
+
+Run: `pnpm type-check`
+Expected: PASS
+
+**Step 4: Commit**
+
+```
+style(portal): reduce sidebar width to 220px
+
+Tighter sidebar gives more room to content. Update both the CSS
+variable and the inline width.
+```
+
+---
+
+## Task 14: Motion Refinements — Card Entrance & Hover Polish
+
+**Files:**
+
+- Modify: `apps/portal/src/styles/utilities.css`
+
+**Step 1: Add card grid stagger animation**
+
+Add after the existing stagger children rules (around line 243):
+
+```css
+/* Card grid stagger - faster delay for dense grids */
+.cp-stagger-grid > * {
+ opacity: 0;
+ animation: cp-fade-up var(--cp-duration-normal) var(--cp-ease-out) forwards;
+}
+
+.cp-stagger-grid > *:nth-child(1) {
+ animation-delay: 0ms;
+}
+.cp-stagger-grid > *:nth-child(2) {
+ animation-delay: 30ms;
+}
+.cp-stagger-grid > *:nth-child(3) {
+ animation-delay: 60ms;
+}
+.cp-stagger-grid > *:nth-child(4) {
+ animation-delay: 90ms;
+}
+.cp-stagger-grid > *:nth-child(5) {
+ animation-delay: 120ms;
+}
+.cp-stagger-grid > *:nth-child(6) {
+ animation-delay: 150ms;
+}
+.cp-stagger-grid > *:nth-child(n + 7) {
+ animation-delay: 180ms;
+}
+```
+
+**Step 2: Add to reduced motion rules**
+
+In the `@media (prefers-reduced-motion: reduce)` block, add `.cp-stagger-grid > *` to the selector list.
+
+**Step 3: Verify**
+
+Run: `pnpm lint`
+Expected: PASS
+
+**Step 4: Commit**
+
+```
+style(portal): add grid stagger animation for subscription cards
+
+Faster 30ms stagger delay for dense grid layouts. Respects
+prefers-reduced-motion.
+```
+
+---
+
+## Task 15: Final Verification & Type Check
+
+**Step 1: Run full type check**
+
+Run: `pnpm type-check`
+Expected: PASS with no errors
+
+**Step 2: Run linting**
+
+Run: `pnpm lint`
+Expected: PASS with no errors
+
+**Step 3: Run tests**
+
+Run: `pnpm test`
+Expected: All existing tests pass (changes are purely visual)
+
+**Step 4: Final commit if any fixes needed**
+
+```
+chore(portal): fix any type or lint issues from UI overhaul
+```
+
+---
+
+## Dependency Graph
+
+```
+Task 1 (fonts) ──────────────┐
+Task 2 (colors) ─────────────┤
+ ├──> Task 3 (sidebar) ──────┐
+ ├──> Task 4 (header) │
+ ├──> Task 5 (StatusIndicator) ├──> Task 9 (grid card) ──> Task 10 (subscriptions page)
+ ├──> Task 6 (MetricCard) ────┘ │
+ ├──> Task 7 (ViewToggle) ─────────────────────────────────┘
+ ├──> Task 8 (FilterBar) ──────────────────────────────────┘
+ ├──> Task 12 (button variant)
+ └──> Task 14 (motion)
+
+Task 11 (dashboard) depends on Task 6 (MetricCard)
+Task 13 (AppShell width) is independent
+Task 15 (verification) depends on all tasks
+```
+
+**Independent task groups that can be parallelized:**
+
+- Group A: Tasks 1, 2 (design tokens — must go first)
+- Group B: Tasks 3, 4, 13 (shell components — after Group A)
+- Group C: Tasks 5, 6, 7, 8, 12, 14 (shared components — after Group A)
+- Group D: Tasks 9, 10 (subscriptions — after Group C)
+- Group E: Task 11 (dashboard — after Task 6)
+- Group F: Task 15 (verification — after all)
diff --git a/docs/plans/image.png b/docs/plans/image.png
new file mode 100644
index 00000000..f4a14437
Binary files /dev/null and b/docs/plans/image.png differ
diff --git a/image.png b/image.png
index ede6718d..f4a14437 100644
Binary files a/image.png and b/image.png differ