diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 739d7a9..b475b4e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -64,10 +64,11 @@ // The main column is its own scroll container (overflow-y-auto) and persists // across navigations, so SvelteKit's window-only scroll restoration never // resets it. Scroll it back to top on forward navigation; leave back/forward - // (popstate) alone so returning to a listing keeps its place. + // (popstate) alone so returning to a listing keeps its place, and hash + // targets (#post-…) alone so the page can place the anchored post itself. let mainEl = $state(null); afterNavigate((nav) => { - if (nav.type !== "popstate") mainEl?.scrollTo(0, 0); + if (nav.type !== "popstate" && !nav.to?.url.hash) mainEl?.scrollTo(0, 0); }); // Nothing is fetched until the relay confirms it will serve this visitor: diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index eea1ac9..63a06be 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -390,12 +390,25 @@ await quotePost(post, text); } + // The scrubber sticks where the OP starts. Measure independently of the + // current scroll (the page may mount already scrolled, e.g. arriving from a + // profile with a reply link) and again when the header changes height. $effect(() => { - if (!opEl) return; + const el = opEl; + const header = threadEl?.querySelector("[data-thread-header]"); + if (!el || !header) return; const main = document.querySelector("main"); if (!main) return; - opTopOffset = - opEl.getBoundingClientRect().top - main.getBoundingClientRect().top; + const measure = () => { + opTopOffset = + el.getBoundingClientRect().top - + main.getBoundingClientRect().top + + main.scrollTop; + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(header); + return () => ro.disconnect(); });