WIP PWA for Grain

feat: app-like layout with fixed header and flex scroll

- Fixed header/bottom nav with scrollable outlet between them
- Flex layout through outlet → pages → feed-layout → pull-to-refresh
- Router saves/restores scroll position on outlet instead of window
- Pull-to-refresh fills available space for empty area triggering
- Update all pages for flex compatibility (align-self: center)
- Remove redundant 100vh/100dvh in favor of 100%

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

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

+48 -26
+7 -1
src/components/molecules/grain-pull-to-refresh.js
··· 13 13 14 14 static styles = css` 15 15 :host { 16 - display: block; 16 + display: flex; 17 + flex-direction: column; 18 + flex: 1; 17 19 overflow: hidden; 20 + min-height: 100%; 18 21 } 19 22 .container { 20 23 position: relative; 24 + flex: 1; 25 + display: flex; 26 + flex-direction: column; 21 27 } 22 28 .indicator { 23 29 position: absolute;
+6 -1
src/components/pages/grain-app.js
··· 30 30 overflow: hidden; 31 31 } 32 32 #outlet { 33 - display: block; 33 + display: flex; 34 + flex-direction: column; 34 35 position: fixed; 35 36 top: 48px; 36 37 left: 0; ··· 38 39 bottom: calc(48px + env(safe-area-inset-bottom, 0px)); 39 40 overflow-y: auto; 40 41 -webkit-overflow-scrolling: touch; 42 + } 43 + #outlet > * { 44 + flex: 0 0 auto; 45 + min-height: 100%; 41 46 } 42 47 `; 43 48
+3 -3
src/components/pages/grain-copyright.js
··· 5 5 static styles = css` 6 6 :host { 7 7 display: block; 8 + width: 100%; 8 9 max-width: var(--feed-max-width); 9 - margin: 0 auto; 10 - min-height: 100vh; 11 - min-height: 100dvh; 10 + min-height: 100%; 12 11 padding-bottom: 80px; 13 12 background: var(--color-bg-primary); 13 + align-self: center; 14 14 } 15 15 .header { 16 16 display: flex;
+3 -3
src/components/pages/grain-create-gallery.js
··· 54 54 static styles = css` 55 55 :host { 56 56 display: block; 57 + width: 100%; 57 58 max-width: var(--feed-max-width); 58 - margin: 0 auto; 59 - min-height: 100vh; 60 - min-height: 100dvh; 59 + min-height: 100%; 61 60 background: var(--color-bg-primary); 61 + align-self: center; 62 62 } 63 63 .header { 64 64 display: flex;
+3 -3
src/components/pages/grain-edit-profile.js
··· 48 48 static styles = css` 49 49 :host { 50 50 display: block; 51 + width: 100%; 51 52 max-width: var(--feed-max-width); 52 - margin: 0 auto; 53 - min-height: 100vh; 54 - min-height: 100dvh; 53 + min-height: 100%; 55 54 padding-bottom: 80px; 56 55 background: var(--color-bg-primary); 56 + align-self: center; 57 57 } 58 58 .header { 59 59 display: flex;
+3 -3
src/components/pages/grain-privacy.js
··· 5 5 static styles = css` 6 6 :host { 7 7 display: block; 8 + width: 100%; 8 9 max-width: var(--feed-max-width); 9 - margin: 0 auto; 10 - min-height: 100vh; 11 - min-height: 100dvh; 10 + min-height: 100%; 12 11 padding-bottom: 80px; 13 12 background: var(--color-bg-primary); 13 + align-self: center; 14 14 } 15 15 .header { 16 16 display: flex;
+3 -1
src/components/pages/grain-profile.js
··· 25 25 26 26 static styles = css` 27 27 :host { 28 - display: block; 28 + display: flex; 29 + flex-direction: column; 30 + min-height: 100%; 29 31 } 30 32 .error { 31 33 padding: var(--space-lg);
+3 -3
src/components/pages/grain-settings.js
··· 12 12 static styles = css` 13 13 :host { 14 14 display: block; 15 + width: 100%; 15 16 max-width: var(--feed-max-width); 16 - margin: 0 auto; 17 - min-height: 100vh; 18 - min-height: 100dvh; 17 + min-height: 100%; 19 18 padding-bottom: 80px; 20 19 background: var(--color-bg-primary); 20 + align-self: center; 21 21 } 22 22 .header { 23 23 display: flex;
+3 -3
src/components/pages/grain-terms.js
··· 5 5 static styles = css` 6 6 :host { 7 7 display: block; 8 + width: 100%; 8 9 max-width: var(--feed-max-width); 9 - margin: 0 auto; 10 - min-height: 100vh; 11 - min-height: 100dvh; 10 + min-height: 100%; 12 11 padding-bottom: 80px; 13 12 background: var(--color-bg-primary); 13 + align-self: center; 14 14 } 15 15 .header { 16 16 display: flex;
+6 -1
src/components/templates/grain-feed-layout.js
··· 3 3 export class GrainFeedLayout extends LitElement { 4 4 static styles = css` 5 5 :host { 6 - display: block; 6 + display: flex; 7 + flex-direction: column; 8 + flex: 1; 7 9 max-width: var(--feed-max-width); 8 10 margin: 0 auto; 9 11 min-height: 100%; 10 12 background: var(--color-bg-primary); 13 + } 14 + ::slotted(grain-pull-to-refresh) { 15 + flex: 1; 11 16 } 12 17 `; 13 18
+8 -4
src/router.js
··· 98 98 const pathname = location.pathname; 99 99 100 100 // Save scroll position of current page before switching 101 - if (this.#currentPath) { 102 - this.#scrollCache.set(this.#currentPath, window.scrollY); 101 + if (this.#currentPath && this.#outlet) { 102 + this.#scrollCache.set(this.#currentPath, this.#outlet.scrollTop); 103 103 } 104 104 105 105 // Skip if same path ··· 130 130 cached.element.dispatchEvent(new CustomEvent('grain:activated')); 131 131 // Restore scroll position after paint 132 132 requestAnimationFrame(() => { 133 - window.scrollTo(0, this.#scrollCache.get(pathname) || 0); 133 + if (this.#outlet) { 134 + this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; 135 + } 134 136 }); 135 137 return; 136 138 } ··· 148 150 149 151 // Restore saved scroll position, or start at top for new pages 150 152 requestAnimationFrame(() => { 151 - window.scrollTo(0, this.#scrollCache.get(pathname) || 0); 153 + if (this.#outlet) { 154 + this.#outlet.scrollTop = this.#scrollCache.get(pathname) || 0; 155 + } 152 156 }); 153 157 } 154 158 }