fix: scroll-to-top refinements

- Fix scroll container detection for shadow DOM
- Position button above bottom nav on desktop
- Style updates: accent background, white icon, border
- Responsive positioning (sticky mobile, fixed desktop)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+50 -15
src
components
+19 -7
src/components/atoms/grain-scroll-to-top.js
··· 8 8 9 9 static styles = css` 10 10 :host { 11 - position: fixed; 12 - bottom: 20px; 13 - left: 20px; 11 + position: sticky; 12 + bottom: var(--space-sm); 13 + left: 0; 14 + align-self: flex-start; 14 15 z-index: 100; 16 + margin-top: auto; 17 + margin-left: var(--space-sm); 18 + } 19 + @media (min-width: 768px) { 20 + :host { 21 + position: fixed; 22 + bottom: calc(57px + env(safe-area-inset-bottom, 0px) + var(--space-lg)); 23 + left: calc(50% - var(--feed-max-width) / 2 - 64px); 24 + margin-left: 0; 25 + margin-top: 0; 26 + } 15 27 } 16 28 button { 17 29 display: flex; 18 30 align-items: center; 19 31 justify-content: center; 20 - width: 48px; 21 - height: 48px; 32 + width: 42px; 33 + height: 42px; 22 34 border-radius: 50%; 23 35 border: 1px solid var(--color-border); 24 - background: var(--color-surface-secondary); 25 - color: var(--color-accent); 36 + background: var(--color-bg-primary); 37 + color: white; 26 38 cursor: pointer; 27 39 opacity: 0; 28 40 pointer-events: none;
+31 -8
src/components/pages/grain-timeline.js
··· 47 47 #observer = null; 48 48 #initialized = false; 49 49 #boundHandleScroll = null; 50 + #scrollContainer = null; 50 51 51 52 constructor() { 52 53 super(); ··· 91 92 this.#fetchTimeline(); 92 93 } 93 94 this.#boundHandleScroll = this.#handleScroll.bind(this); 94 - window.addEventListener('scroll', this.#boundHandleScroll, { passive: true }); 95 + this.#scrollContainer = this.#findScrollContainer(); 96 + (this.#scrollContainer || window).addEventListener('scroll', this.#boundHandleScroll, { passive: true }); 97 + } 98 + 99 + #findScrollContainer() { 100 + let el = this; 101 + while (el) { 102 + const parent = el.parentElement || el.getRootNode()?.host; 103 + if (!parent || parent === document.documentElement) break; 104 + 105 + const style = getComputedStyle(parent); 106 + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { 107 + return parent; 108 + } 109 + el = parent; 110 + } 111 + return null; 95 112 } 96 113 97 114 disconnectedCallback() { 98 115 super.disconnectedCallback(); 99 116 this.#observer?.disconnect(); 100 117 if (this.#boundHandleScroll) { 101 - window.removeEventListener('scroll', this.#boundHandleScroll); 118 + (this.#scrollContainer || window).removeEventListener('scroll', this.#boundHandleScroll); 102 119 } 103 120 } 104 121 ··· 203 220 } 204 221 205 222 #handleScroll() { 206 - this._showScrollTop = window.scrollY > 150; 223 + const scrollTop = this.#scrollContainer ? this.#scrollContainer.scrollTop : window.scrollY; 224 + this._showScrollTop = scrollTop > 150; 207 225 } 208 226 209 227 async #handleScrollTop() { 210 228 if (this._refreshing) return; 211 229 212 - window.scrollTo({ top: 0, behavior: 'smooth' }); 230 + if (this.#scrollContainer) { 231 + this.#scrollContainer.scrollTo({ top: 0, behavior: 'smooth' }); 232 + } else { 233 + window.scrollTo({ top: 0, behavior: 'smooth' }); 234 + } 213 235 214 236 // Wait for scroll to complete before refreshing 215 237 await new Promise(resolve => setTimeout(resolve, 400)); ··· 249 271 focusPhotoUrl=${this._focusPhotoUrl || ''} 250 272 @close=${this.#handleCommentSheetClose} 251 273 ></grain-comment-sheet> 274 + 275 + <grain-scroll-to-top 276 + ?visible=${this._showScrollTop} 277 + @scroll-top=${this.#handleScrollTop} 278 + ></grain-scroll-to-top> 252 279 </grain-feed-layout> 253 - <grain-scroll-to-top 254 - ?visible=${this._showScrollTop} 255 - @scroll-top=${this.#handleScrollTop} 256 - ></grain-scroll-to-top> 257 280 `; 258 281 } 259 282 }