From 0d1124d20d6620784094b48b07f0dfe49fe66258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Montes=20Linares?= <128893712+aMonteSl@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:37:21 +0200 Subject: [PATCH] fix(hero): centre the hero on desktop instead of leaving the sidebar band empty From xl the home content keeps a 19rem left offset for the fixed sidebar, but the sidebar is hidden while the hero is on screen. The hero sat in that offset column: an empty band on the left, the text running into the right edge, and nothing lined up with the full-width header. - The hero cancels the offset at xl (SIDEBAR_BLEED_CLASS) and centres in the same max-w-7xl column as the header. - At xl the header-to-sidebar morph is measured from the hero: the crossfade runs over the last 30% of the viewport before the hero's bottom reaches the header, after its left column has scrolled away, so the sidebar never covers it. MorphSidebar fades in over the same range. - Below xl nothing changes (fixed 100-450px range for the header and the floating menu button). Co-Authored-By: Claude Opus 5.5 --- src/features/landing/Hero.tsx | 3 +- src/features/morphNav/MorphNavProvider.tsx | 54 ++++++++++++++++++++-- src/features/morphNav/MorphSidebar.tsx | 9 ++-- src/features/morphNav/index.ts | 1 + src/features/morphNav/layout.ts | 6 +++ 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/src/features/landing/Hero.tsx b/src/features/landing/Hero.tsx index 243974c..ae30e49 100644 --- a/src/features/landing/Hero.tsx +++ b/src/features/landing/Hero.tsx @@ -18,6 +18,7 @@ import { useTranslations, useLocale } from '@/i18n' import { SOCIAL_LINKS, getCvUrl } from '@/lib/constants' import { useEmailCopyFeedback } from '@/lib/hooks/useEmailCopyFeedback' import { FEATURED_PROJECTS } from '@/content/featuredProjects' +import { SIDEBAR_BLEED_CLASS } from '@/features/morphNav/layout' import { CURRENT_ROLE_ID } from '@/content/journey' import { useEntryPhase } from '@/features/journey' import { useFeaturedRotation } from './useFeaturedRotation' @@ -67,7 +68,7 @@ export function Hero() { } return ( - +
{/* CSS entrance (.hero-enter): the first screen is visible before hydration */}
diff --git a/src/features/morphNav/MorphNavProvider.tsx b/src/features/morphNav/MorphNavProvider.tsx index ad426cb..0dd827a 100644 --- a/src/features/morphNav/MorphNavProvider.tsx +++ b/src/features/morphNav/MorphNavProvider.tsx @@ -13,9 +13,17 @@ import { useScrollProgress } from '@/features/morphNav/useScrollProgress' import { SIDEBAR_MEDIA_QUERY } from '@/features/morphNav/layout' import { NAV_ITEMS } from '@/lib/constants' +/** Scroll positions (px) where the header starts handing over to the sidebar and where it is done */ +export interface MorphRange { + start: number + end: number +} + interface MorphNavContextValue { /** Scroll progress from 0 (header) to 1 (sidebar) */ progress: number + /** Scroll range of the morph; the desktop sidebar fades in over the same range */ + morphRange: MorphRange /** Whether sidebar is fully visible */ isMorphed: boolean /** Whether currently transitioning */ @@ -51,10 +59,47 @@ export function MorphNavProvider({ morphStart = 100, morphEnd = 400, }: MorphNavProviderProps) { - const { progress, isMorphed, isMorphing, prefersReducedMotion } = useScrollProgress({ - start: morphStart, - end: morphEnd, - }) + // From xl the hero spans the viewport (the sidebar is hidden while it is on screen), so the header only + // hands over to the sidebar once the hero is almost gone; otherwise the sidebar would cover its left column. + // Below xl the fixed props drive the header fade and the floating menu button. + const [desktopRange, setDesktopRange] = useState(null) + + useEffect(() => { + const mediaQuery = window.matchMedia(SIDEBAR_MEDIA_QUERY) + const hero = document.getElementById('home') + + const measure = () => { + if (!mediaQuery.matches || !hero) { + setDesktopRange(null) + return + } + + // Done when the hero's bottom edge reaches the 64px header; the crossfade covers the 30% of the + // viewport before that, by which point the hero's left column has scrolled out of view. + const heroBottom = hero.offsetTop + hero.offsetHeight + const end = Math.max(morphEnd, Math.round(heroBottom - 64)) + const start = Math.max(morphStart, Math.round(end - Math.max(200, window.innerHeight * 0.3))) + + setDesktopRange((current) => ( + current?.start === start && current.end === end ? current : { start, end } + )) + } + + measure() + const resizeObserver = new ResizeObserver(measure) + if (hero) resizeObserver.observe(hero) + window.addEventListener('resize', measure) + mediaQuery.addEventListener('change', measure) + + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', measure) + mediaQuery.removeEventListener('change', measure) + } + }, [morphStart, morphEnd]) + + const morphRange: MorphRange = desktopRange ?? { start: morphStart, end: morphEnd } + const { progress, isMorphed, isMorphing, prefersReducedMotion } = useScrollProgress(morphRange) const [activeSection, setActiveSection] = useState('home') const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -148,6 +193,7 @@ export function MorphNavProvider({ const contextValue: MorphNavContextValue = { progress, + morphRange, isMorphed, isMorphing, activeSection, diff --git a/src/features/morphNav/MorphSidebar.tsx b/src/features/morphNav/MorphSidebar.tsx index 89c2c7f..c8b673f 100644 --- a/src/features/morphNav/MorphSidebar.tsx +++ b/src/features/morphNav/MorphSidebar.tsx @@ -12,15 +12,14 @@ import { cn } from '@/lib/utils' * Below `xl` the same content lives in the drawer rendered by `MorphDrawer`. */ export function MorphSidebar() { - const { isMorphed, prefersReducedMotion } = useMorphNav() + const { isMorphed, prefersReducedMotion, morphRange } = useMorphNav() // Use Framer Motion's useScroll for smooth scroll-based animations const { scrollY, scrollYProgress } = useScroll() - // Transform scroll position to sidebar reveal values - // Sidebar starts appearing at 120px and is fully visible at 520px - const sidebarOpacity = useTransform(scrollY, [120, 520], [0, 1]) - const sidebarX = useTransform(scrollY, [120, 520], [-24, 0]) + // The sidebar fades in over the same range the header fades out (see MorphNavProvider) + const sidebarOpacity = useTransform(scrollY, [morphRange.start, morphRange.end], [0, 1]) + const sidebarX = useTransform(scrollY, [morphRange.start, morphRange.end], [-24, 0]) const pageProgressScale = useTransform(scrollYProgress, [0, 1], [0.02, 1]) return ( diff --git a/src/features/morphNav/index.ts b/src/features/morphNav/index.ts index 434ef71..b46a0b1 100644 --- a/src/features/morphNav/index.ts +++ b/src/features/morphNav/index.ts @@ -8,6 +8,7 @@ export { useScrollProgress } from './useScrollProgress' export { SIDEBAR_WIDTH_CLASS, SIDEBAR_CONTENT_OFFSET_CLASS, + SIDEBAR_BLEED_CLASS, SIDEBAR_RECENTER_CLASS, SIDEBAR_MEDIA_QUERY, } from './layout' diff --git a/src/features/morphNav/layout.ts b/src/features/morphNav/layout.ts index 011a8fa..7e2e10d 100644 --- a/src/features/morphNav/layout.ts +++ b/src/features/morphNav/layout.ts @@ -11,6 +11,12 @@ export const SIDEBAR_WIDTH_CLASS = 'w-[17rem]' /** Applied to the home content and footer at xl+ (17rem sidebar + 2rem gutter). */ export const SIDEBAR_CONTENT_OFFSET_CLASS = 'xl:ml-[17rem] xl:pl-8' +/** + * Lets the hero cancel that offset and span the viewport at xl: the sidebar is still hidden while the + * hero is on screen, so the hero centres under the header instead of leaving an empty 19rem band. + */ +export const SIDEBAR_BLEED_CLASS = 'xl:-ml-[19rem]' + /** Below xl the floating menu button (56px, bottom-left) would cover the end of the page; this clears it. */ export const FLOATING_MENU_CLEARANCE_CLASS = 'max-xl:pb-[calc(5rem+env(safe-area-inset-bottom))]'