Dev notes
URL-based version routing, bundle splitting with next/dynamic, and a clean slate for v2 without touching a single line of v1 code.
The landing page and feature hub depend on Three.js, R3F, drei, shader-gradient, and WeatherCanvas. That's a lot of JS for users who just want to see what's here. Redesigning in place means either maintaining two code paths in the same files or doing a risky big-bang swap. Neither is great.
The solution is a URL parameter: ?version=v1 serves the original experience, and the default path (no param or any other value) serves v2. The server component reads searchParams.version from the page props and branches on it. No middleware, no cookies, no feature flags -- just a query string that anyone can type.
The v1 components (LandingContent and FeatureHub) are wrapped in next/dynamic. That means their heavy dependencies -- Three.js, R3F, shader-gradient, WeatherCanvas -- are split into separate chunks that only load when the URL includes ?version=v1. The v2 components are statically imported because they're lightweight -- no 3D dependencies to defer.
This is the key win: the default path ships zero Three.js bytes. Users who want the original experience can still get it, and the bundle cost only hits when they ask for it.
A URL parameter is transparent and shareable. You can send someone a link to the v1 experience and they see exactly what you see. Cookies are invisible and sticky -- they create debugging nightmares where "it works for me" is literally true because your cookie state differs. Feature flags add infrastructure (a provider, an API call, a dashboard) for what is fundamentally a two-value switch.
LandingContent.tsx and FeatureHub.tsx are completely untouched. They still work exactly as before -- they're just loaded through next/dynamic instead of a static import. The v2 components live in their own src/app/v2/ directory. No shared state, no conditional rendering within components, no risk of a v2 change breaking v1.
The v2 nav is a fixed bar that starts fully transparent and picks up a frosted-glass treatment (backdrop-blur-xl bg-background/80) once you scroll past 50px. A scroll listener flips a boolean in state and the background change runs through transition-all duration-300.
The right side is auth-aware: unauthenticated users see a "Log in" link pointing at /auth/login, authenticated users see a settings gear linking to /settings. The authenticated prop is a boolean passed from the server component that already has the session -- no client-side auth check needed.
The v2 hero replaces the Three.js globe and ShaderGradient with a CSS-only ambient gradient -- two overlapping radial gradients (violet and blue tints) on a background-size: 400% 400% canvas that drifts on a 20-second keyframe loop. Dark mode pushes the tint opacity slightly higher so the color reads against a near-black base. Light mode keeps them barely visible. No canvas, no WebGL, no JS cost for the background.
The headline uses Framer Motion's staggered word reveal -- each word in "Hey, I'm Paul. / I build things / people use." fades up from 20px below with spring.wordReveal physics. The subtitle and CTA button fade in after the headline completes, timed off the word count so the delay stays correct if the copy changes.
LCP safety comes from the useSyncExternalStore mounted flag -- the same pattern v1 uses. SSR renders the H1 text visible (initial={false} server-side), and the entrance animation only runs after hydration. The useReducedMotion() guard replaces all spring transitions with instantTransition when the user has requested reduced motion.
Each project gets a full-width card with a two-column layout on desktop -- 60% preview area, 40% text content. The reversed prop swaps the columns so alternating cards zigzag down the page. On mobile the columns stack vertically with the preview on top.
The preview area gets a subtle background tinted with the feature's color at ~5% opacity. The text side has a category dot (matching the color), the title, a description, and one or two links: "View project →" always, "Read about it →" when there's a matching thoughts page. The card accepts a preview ReactNode so it can render the same mini-preview components the v1 hub uses -- no new preview code needed.
Animation is scroll-triggered via whileInView with a -10% viewport margin so cards start animating slightly before they enter the viewport. Each card staggers by index * 0.1s. Hover lifts the card 4px. All motion respects useReducedMotion().
A full-bleed horizontal strip that shows four key stats: 14 features, 108+ tests, 17 write-ups, and 5 CWV metrics tracked. The numbers count up from 0 to their target over 1.5 seconds with ease-out cubic easing when the section scrolls into view. The animation is driven by the existing useCountUp hook, extended with an optional inView parameter wired to Framer Motion's useInView.
SSR renders the final values using the useSyncExternalStore mounted flag -- same pattern as the hero. The server pass shows "14", "108+", etc. so crawlers and no-JS users see real content. After hydration the client resets to 0 and waits for scroll intersection before counting up. Users with prefers-reduced-motion see the final values immediately with no animation.
The authenticated hub got the same treatment as the landing page. NavBar with authenticated={true}, a generous header section with a personalized greeting ("Hey {firstName}."), feature count subtitle, and inline stats line. Same useQuery pattern as v1 -- server-seeded initialMe so the name renders on first paint, background refresh after 5 minutes.
Below the header: pill-shaped category filter tabs (All, NBA, Pokemon, Calendar, Engineering, Labs) that filter the FEATURES array client-side. Active tab gets bg-foreground text-background, inactive tabs get bg-surface text-muted. The tab bar scrolls horizontally on mobile with a hidden scrollbar. The filtered grid re-staggers on category change via a Framer Motion key={active} swap. Dev thoughts section reuses the same ThoughtCard and scroll-triggered reveal as v1.
LandingContentV2 was a placeholder div. Now it composes all six sections in order: NavBar (unauthenticated), HeroSection, ProjectsSection, StatsStrip, ThoughtsPreview, FooterSection. The outer wrapper applies scroll-smooth bg-background so the hero's "Explore my work" CTA and the footer's anchor links animate smoothly rather than jumping.
No changes to page.tsx -- it already rendered LandingContentV2 for unauthenticated non-v1 visitors. The hero is a full-viewport section (min-h-dvh) so the fixed NavBar overlaps it naturally without needing explicit top padding.
A minimal server component footer -- no client JS, no animations. Three elements in a row on desktop: the "paul-explore" wordmark, a copyright year, and a nav with links to GitHub, the thoughts index, and a "View v1 →" link that appends ?version=v1 to the URL. On mobile the three elements stack vertically and center-align. The GitHub link opens in a new tab with rel="noopener noreferrer".
A "How it's built" section that renders every entry from the shared THOUGHTS array as a linked card in a responsive grid -- single column on mobile, two on tablet, three on desktop. Each card has a 3px left accent border in the thought's color, a bold title, and preview text. Hover darkens the border and adds a subtle shadow.
The grid entrance uses Framer Motion's staggerContainer with 50ms delay per card and whileInView so cards fade up as the section scrolls into view. All motion is guarded by useReducedMotion().
The projects section is the main showcase -- it renders all 14 features as ProjectCard components, grouped by category. Six groups: Fantasy & NBA (5 cards), Pokémon (3), Productivity (1), Engineering (2), Labs & Learning (2), and Social (1). Each group gets a sticky category label (text-xs uppercase tracking-widest text-muted font-bold) with a thin horizontal rule underneath. The label sticks to the top of the viewport as you scroll through that group's cards, so you always know which category you're in.
Cards within each group alternate the reversed prop so the preview/text sides zig-zag down the page. The section has id="projects" so the hero's "Explore my work" CTA scrolls directly to it. Features are looked up by ID from the shared FEATURES array and PREVIEW_MAP, so the grouping order is defined entirely by the GROUPS config -- independent of the array's own order.
The v1 FeatureHub had ~1000 lines of inline data: the FEATURES and THOUGHTS arrays, 14 mini-preview components, all the static data those previews depend on, the PREVIEW_MAP and FEATURE_TOKEN lookups, and the FeatureCard and ThoughtCard presentational components. All of that lived in FeatureHub.tsx because it was the only consumer.
With v2, there are two consumers. Extracting everything into src/app/_shared/featureData.tsx lets both v1's FeatureHub and v2's ProjectCard layout import the same feature list, preview components, and card components. FeatureHub.tsx drops to just its default export and internal logic -- no behavioral change, just a different import path.
Every v2 component was audited for theme and breakpoint correctness. All colors use design token classes (text-foreground, text-muted, bg-background, bg-surface, border-border) -- no hardcoded hex values for theme-dependent colors. The only inline color styles are feature accent colors, which are intentionally fixed.
Responsive breakpoints verified at 375px, 768px, and 1280px: hero text scales down (text-5xl/6xl/7xl), project cards stack on mobile, stats go to 2x2 grid, thoughts go single-column, footer stacks vertically. The ambient gradient has an explicit dark variant with higher opacity in hero.module.css. The nav blur uses token-based bg-background/80 so it tints correctly in both themes.
Seven-point verification before shipping: reduced motion, TypeScript build, bundle splitting, hydration safety, LCP, CLS, and tests. Every Framer Motion animation has a useReducedMotion() guard -- delays collapse to zero, transitions become instant, the scroll indicator stops bobbing, and count-up values jump straight to their target.
Bundle splitting confirmed: zero 3D imports in src/app/v2/. The 19 chunks containing Three.js, R3F, and shader-gradient are all lazy-loaded via next/dynamic and only fetched when ?version=v1 is in the URL. Hydration is safe: HeroSection and StatsStrip use the useSyncExternalStore mounted flag so SSR renders the final visible state and animation only plays after hydration. Below-fold components use whileInView which avoids the mismatch entirely. 421 tests passing, no regressions.
As the version routing grew from a two-branch if/else into something that could support v3 and beyond, the page.tsx rendering logic was refactored into a registry pattern. A VERSIONS object maps each version key to a { Landing, Hub } component pair. A CURRENT_VERSION constant controls which version is the default, and a resolveVersion() helper validates the URL param against the registry keys.
The rendering path is now a single flow: look up the version, destructure its components, render the appropriate one based on auth state, and wrap with VersionBanner if it's not the current version. Adding a v3 is one entry in the registry object -- no branching logic to update. The satisfies constraint ensures every version entry provides both a Landing and Hub component with the correct prop types, so TypeScript catches mismatches at compile time.
When ?version=v1 is in the URL, a thin fixed amber banner appears at the very top of the page: "You're viewing v1 — switch to current ↗". The link points to / with no version param, which loads v2. The banner sits at z-50 above the v1 nav, and the v1 content is wrapped in a pt-8 div to push it below the banner. Dark mode flips the text to text-amber-300. The banner is a server component — no JS needed.