11'use client' ;
22
3- import { useState , useEffect } from 'react' ;
3+ import { useState , useEffect , useRef } from 'react' ;
44import { useQuery , useMutation , useQueryClient } from '@tanstack/react-query' ;
55import { useGastownTRPC } from '@/lib/gastown/trpc' ;
66import { useUser } from '@/hooks/useUser' ;
@@ -49,10 +49,12 @@ const SECTIONS = [
4949
5050function useScrollSpy ( sectionIds : readonly string [ ] ) {
5151 const [ activeId , setActiveId ] = useState < string > ( sectionIds [ 0 ] ) ;
52+ const suppressRef = useRef ( false ) ;
5253
5354 useEffect ( ( ) => {
5455 const observer = new IntersectionObserver (
5556 entries => {
57+ if ( suppressRef . current ) return ;
5658 // Find the topmost visible section
5759 const visible = entries
5860 . filter ( e => e . isIntersecting )
@@ -61,7 +63,7 @@ function useScrollSpy(sectionIds: readonly string[]) {
6163 setActiveId ( visible [ 0 ] . target . id ) ;
6264 }
6365 } ,
64- { rootMargin : '-80px 0px -60% 0px' , threshold : 0 }
66+ { rootMargin : '-56px 0px -60% 0px' , threshold : 0 }
6567 ) ;
6668
6769 for ( const id of sectionIds ) {
@@ -72,14 +74,26 @@ function useScrollSpy(sectionIds: readonly string[]) {
7274 return ( ) => observer . disconnect ( ) ;
7375 } , [ sectionIds ] ) ;
7476
75- return activeId ;
76- }
77+ function scrollTo ( id : string ) {
78+ const el = document . getElementById ( id ) ;
79+ const header = document . getElementById ( 'settings-sticky-header' ) ;
80+ if ( ! el ) return ;
81+
82+ // Immediately highlight the target and suppress observer during scroll
83+ setActiveId ( id ) ;
84+ suppressRef . current = true ;
7785
78- function scrollToSection ( id : string ) {
79- const el = document . getElementById ( id ) ;
80- if ( el ) {
81- el . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ;
86+ const headerHeight = header ?. getBoundingClientRect ( ) . height ?? 0 ;
87+ const top = el . getBoundingClientRect ( ) . top + window . scrollY - headerHeight - 24 ;
88+ window . scrollTo ( { top : Math . max ( 0 , top ) , behavior : 'smooth' } ) ;
89+
90+ // Re-enable observer after scroll settles
91+ setTimeout ( ( ) => {
92+ suppressRef . current = false ;
93+ } , 1000 ) ;
8294 }
95+
96+ return { activeId, scrollTo } ;
8397}
8498
8599export function TownSettingsPageClient ( { townId, readOnly = false } : Props ) {
@@ -149,7 +163,9 @@ export function TownSettingsPageClient({ townId, readOnly = false }: Props) {
149163 setInitialized ( true ) ;
150164 }
151165
152- const activeSection = useScrollSpy ( SECTIONS . map ( s => s . id ) ) ;
166+ const { activeId : activeSection , scrollTo : scrollToSection } = useScrollSpy (
167+ SECTIONS . map ( s => s . id )
168+ ) ;
153169
154170 function handleSave ( ) {
155171 const envVarObj : Record < string , string > = { } ;
@@ -228,9 +244,12 @@ export function TownSettingsPageClient({ townId, readOnly = false }: Props) {
228244 }
229245
230246 return (
231- < div className = "flex h-full flex-col" >
247+ < div >
232248 { /* Top bar */ }
233- < div className = "flex items-center justify-between border-b border-white/[0.06] px-6 py-3" >
249+ < div
250+ id = "settings-sticky-header"
251+ className = "sticky top-0 z-10 flex items-center justify-between border-b border-white/[0.06] bg-[oklch(0.1_0_0)] px-6 py-3"
252+ >
234253 < div className = "flex items-center gap-2.5" >
235254 < Settings className = "size-4 text-white/40" />
236255 < h1 className = "text-lg font-semibold tracking-tight text-white/90" > Settings</ h1 >
@@ -255,12 +274,12 @@ export function TownSettingsPageClient({ townId, readOnly = false }: Props) {
255274 ) }
256275 </ div >
257276
258- { /* Two-column body — single scroll container so sticky works */ }
259- < div className = "flex-1 overflow-y-auto scroll-smooth" >
260- < div className = "flex" >
277+ { /* Two-column body — viewport scrolls so sticky works */ }
278+ < div className = "scroll-smooth" >
279+ < div className = "mx-auto flex max-w-4xl px-6 " >
261280 { /* Main content */ }
262281 < div className = "min-w-0 flex-1" >
263- < div className = "mx-auto max-w-2xl space-y-8 px-6 pt-6 pb-24" >
282+ < div className = "space-y-8 pt-6" style = { { paddingBottom : '75vh' } } >
264283 { /* ── Git Authentication ──────────────────────────────── */ }
265284 < SettingsSection
266285 id = "git-auth"
@@ -617,8 +636,8 @@ export function TownSettingsPageClient({ townId, readOnly = false }: Props) {
617636 </ div >
618637
619638 { /* Right sidebar — sticky scrollspy nav */ }
620- < div className = "hidden w-52 shrink-0 lg:block" >
621- < nav className = "sticky top-6 px-4 pt-6" >
639+ < div className = "hidden w-52 shrink-0 lg:sticky lg:top-[53px] lg:self-start lg: block" >
640+ < nav className = "px-4 pt-6" >
622641 < div className = "mb-3 text-[10px] font-medium tracking-wide text-white/25 uppercase" >
623642 On this page
624643 </ div >
@@ -631,19 +650,23 @@ export function TownSettingsPageClient({ townId, readOnly = false }: Props) {
631650 < li key = { section . id } >
632651 < button
633652 onClick = { ( ) => scrollToSection ( section . id ) }
634- className = { `flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors ${
653+ className = { `flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs whitespace-nowrap overflow-hidden text-ellipsis transition-colors ${
635654 isActive
636655 ? 'bg-white/[0.06] text-white/80'
637656 : 'text-white/35 hover:bg-white/[0.03] hover:text-white/55'
638657 } `}
639658 >
640659 < SectionIcon className = "size-3 shrink-0" />
641- < span > { section . label } </ span >
660+ < span className = "truncate" > { section . label } </ span >
642661 { isActive && (
643662 < motion . div
644663 layoutId = "settings-nav-indicator"
645664 className = "ml-auto size-1 rounded-full bg-[color:oklch(95%_0.15_108)]"
646- transition = { { type : 'spring' , stiffness : 350 , damping : 30 } }
665+ transition = { {
666+ type : 'spring' ,
667+ stiffness : 350 ,
668+ damping : 30 ,
669+ } }
647670 />
648671 ) }
649672 </ button >
@@ -700,7 +723,6 @@ function SettingsSection({
700723 initial = { { opacity : 0 , y : 16 } }
701724 animate = { { opacity : 1 , y : 0 } }
702725 transition = { { delay : index * 0.06 , duration : 0.35 } }
703- className = "scroll-mt-6"
704726 >
705727 < div className = "mb-4 flex items-start justify-between" >
706728 < div className = "flex items-start gap-3" >
0 commit comments