@@ -6,8 +6,10 @@ import clsx from 'clsx';
66import { NeonButton } from '@/components/common' ;
77import { NAV_LINKS , SITE } from '@/content/landing' ;
88import useActiveSection from '@/hooks/useActiveSection' ;
9+ import useHashLanding from '@/hooks/useHashLanding' ;
910import useHeaderScroll from '@/hooks/useHeaderScroll' ;
1011import { openApp } from '@/lib/openApp' ;
12+ import { findSection , scrollToSection } from '@/lib/scrollToSection' ;
1113import './SiteHeader.css' ;
1214
1315/** Module scope keeps the array referentially stable across renders. */
@@ -16,113 +18,6 @@ const NAV_IDS = NAV_LINKS.map(({ href }) => href.replace('#', ''));
1618/** Width at which the inline nav gives way to the drawer. Matches SiteHeader.css. */
1719const DRAWER_QUERY = '(max-width: 900px)' ;
1820
19- /**
20- * How far `target` is from where it should land, in px.
21- *
22- * Two cases, because the header is a floating pill and not an opaque bar —
23- * nothing hides the strip of viewport around it, so a landing must never leave
24- * the previous section visible in that strip:
25- *
26- * - A section with a full-viewport pinned scene lands flush at the viewport
27- * top. The scene owns the whole screen and pads its own content clear of the
28- * glass.
29- * - Any other section lands with its content resting at the anchor offset when
30- * its own top padding is deep enough to reach the border, and at the
31- * header's edge otherwise — never higher, so the heading cannot tuck under
32- * the glass, and never lower than its padding can cover, so the previous
33- * section's tail cannot show above the border.
34- *
35- * The offset is read back from the resolved `scroll-padding-top`: custom
36- * properties do not resolve calc() through getComputedStyle, real properties
37- * do, so this is the one place the number exists and JS and CSS cannot drift.
38- */
39- const landingError = ( target : Element ) => {
40- const pin = target . querySelector ( '.pin' ) ;
41- if ( pin && getComputedStyle ( pin ) . position === 'sticky' ) {
42- return Math . round ( target . getBoundingClientRect ( ) . top ) ;
43- }
44-
45- const root = getComputedStyle ( document . documentElement ) ;
46- const offset = parseFloat ( root . scrollPaddingTop ) || 0 ;
47- const gap = parseFloat ( root . getPropertyValue ( '--anchor-gap' ) ) || 0 ;
48- const headerHeight = offset - gap ;
49-
50- const padding = parseFloat ( getComputedStyle ( target ) . paddingTop ) || 0 ;
51- const contentY = Math . max ( headerHeight , Math . min ( padding , offset ) ) ;
52-
53- return Math . round ( target . getBoundingClientRect ( ) . top + padding - contentY ) ;
54- } ;
55-
56- /**
57- * Once the scroll has come to rest, re-measure and instantly remove whatever
58- * error remains. A single scrollTo cannot be pixel-perfect: any layout shift
59- * while the animation runs — an image decoding, a font swapping, dvh settling —
60- * moves the target by exactly the amount the landing ends up off by.
61- *
62- * Cancelled the moment the reader scrolls themselves, so the correction can
63- * never yank the page away from someone who changed their mind mid-flight.
64- */
65- const settleOnArrival = ( target : Element ) => {
66- let cancelled = false ;
67-
68- const cancel = ( ) => {
69- cancelled = true ;
70- cleanup ( ) ;
71- } ;
72-
73- const cleanup = ( ) => {
74- window . removeEventListener ( 'wheel' , cancel ) ;
75- window . removeEventListener ( 'touchstart' , cancel ) ;
76- window . removeEventListener ( 'keydown' , cancel ) ;
77- window . removeEventListener ( 'scrollend' , onEnd ) ;
78- } ;
79-
80- const correct = ( ) => {
81- if ( cancelled ) return ;
82- const error = landingError ( target ) ;
83- if ( Math . abs ( error ) > 1 ) window . scrollBy ( { top : error , behavior : 'auto' } ) ;
84- } ;
85-
86- const onEnd = ( ) => {
87- cleanup ( ) ;
88- correct ( ) ;
89- } ;
90-
91- window . addEventListener ( 'wheel' , cancel , { passive : true , once : true } ) ;
92- window . addEventListener ( 'touchstart' , cancel , { passive : true , once : true } ) ;
93- window . addEventListener ( 'keydown' , cancel , { once : true } ) ;
94-
95- if ( 'onscrollend' in window ) {
96- window . addEventListener ( 'scrollend' , onEnd , { once : true } ) ;
97- } else {
98- // Safari has no scrollend: treat three frames without movement as arrival.
99- let last = - 1 ;
100- let still = 0 ;
101- const tick = ( ) => {
102- if ( cancelled ) return ;
103- const y = window . scrollY ;
104- if ( Math . abs ( y - last ) < 1 ) {
105- if ( ++ still >= 3 ) {
106- cleanup ( ) ;
107- correct ( ) ;
108- return ;
109- }
110- } else {
111- still = 0 ;
112- }
113- last = y ;
114- requestAnimationFrame ( tick ) ;
115- } ;
116- requestAnimationFrame ( tick ) ;
117- }
118- } ;
119-
120- const scrollToTarget = ( target : Element , smooth : boolean ) => {
121- const top = window . scrollY + landingError ( target ) ;
122- window . scrollTo ( { top : Math . max ( top , 0 ) , behavior : smooth ? 'smooth' : 'auto' } ) ;
123- settleOnArrival ( target ) ;
124- } ;
125-
12621type SiteHeaderProps = {
12722 title : string ;
12823} ;
@@ -138,49 +33,31 @@ export default function SiteHeader({ title }: SiteHeaderProps) {
13833
13934 const close = useCallback ( ( ) => setOpen ( false ) , [ ] ) ;
14035
36+ useHashLanding ( ) ;
37+
14138 /**
142- * Scrolls to a section explicitly rather than leaving it to the browser's
143- * anchor jump: the jump cannot skip a section's own top padding, and the
144- * settle pass in scrollToTarget is what makes the landing exact.
39+ * Takes over the anchor jump, which cannot skip a section's own top padding.
40+ * The landing itself is `scrollToSection`'s problem, not the header's.
14541 */
14642 const goToSection = useCallback (
14743 ( event : MouseEvent < HTMLAnchorElement > , href : string , fromDrawer = false ) => {
14844 // Leave modified clicks alone so open-in-new-tab still works.
14945 if ( event . metaKey || event . ctrlKey || event . shiftKey || event . altKey ) return ;
15046
151- const target = document . querySelector ( href ) ;
47+ const target = findSection ( href ) ;
15248 if ( ! target ) return ;
15349
15450 event . preventDefault ( ) ;
15551 close ( ) ;
15652 if ( fromDrawer ) toggleRef . current ?. focus ( ) ;
15753
15854 const reduced = window . matchMedia ( '(prefers-reduced-motion: reduce)' ) . matches ;
159- scrollToTarget ( target , ! reduced ) ;
55+ scrollToSection ( target , { smooth : ! reduced } ) ;
16056 window . history . pushState ( null , '' , href ) ;
16157 } ,
16258 [ close ] ,
16359 ) ;
16460
165- // Arriving with a hash in the URL takes the browser's native jump, which
166- // lands on the border box with the fallback offset. Correct it once layout
167- // has something real to measure.
168- useEffect ( ( ) => {
169- const { hash } = window . location ;
170- if ( ! hash || hash === '#top' ) return ;
171-
172- let target : Element | null = null ;
173- try {
174- target = document . querySelector ( hash ) ;
175- } catch {
176- return ; // Not a valid selector — an external tool's tracking hash.
177- }
178- if ( ! target ) return ;
179-
180- const frame = requestAnimationFrame ( ( ) => scrollToTarget ( target , false ) ) ;
181- return ( ) => cancelAnimationFrame ( frame ) ;
182- } , [ ] ) ;
183-
18461 // Slide the indicator behind the active link. Measured rather than expressed
18562 // in CSS because the links are content-width, so the pill's offset and width
18663 // are only knowable from layout.
0 commit comments