WIP PWA for Grain

docs: add profile skeleton implementation plan

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

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

+191
+191
docs/plans/2025-12-29-profile-skeleton-implementation.md
··· 1 + # Profile Header Skeleton Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a skeleton loader for the profile header to eliminate layout shift on fresh profile loads. 6 + 7 + **Architecture:** Create a new Lit component that mirrors the exact layout dimensions of `grain-profile-header`, using animated placeholder shapes. Swap the spinner for this skeleton during loading state. 8 + 9 + **Tech Stack:** Lit 3.x, CSS custom properties, CSS animations 10 + 11 + --- 12 + 13 + ### Task 1: Create the Skeleton Component 14 + 15 + **Files:** 16 + - Create: `src/components/molecules/grain-profile-header-skeleton.js` 17 + 18 + **Step 1: Create the skeleton component file** 19 + 20 + Create `src/components/molecules/grain-profile-header-skeleton.js`: 21 + 22 + ```javascript 23 + import { LitElement, html, css } from 'lit'; 24 + 25 + export class GrainProfileHeaderSkeleton extends LitElement { 26 + static styles = css` 27 + :host { 28 + display: block; 29 + padding: var(--space-md) var(--space-sm); 30 + } 31 + @media (min-width: 600px) { 32 + :host { 33 + padding-left: 0; 34 + padding-right: 0; 35 + } 36 + } 37 + .top-row { 38 + display: flex; 39 + align-items: flex-start; 40 + gap: var(--space-md); 41 + margin-bottom: var(--space-sm); 42 + } 43 + .right-column { 44 + flex: 1; 45 + min-width: 0; 46 + padding-top: var(--space-xs); 47 + } 48 + .placeholder { 49 + background: var(--color-bg-elevated); 50 + border-radius: 4px; 51 + animation: pulse 1.5s ease-in-out infinite; 52 + } 53 + .avatar { 54 + width: var(--avatar-size-lg, 80px); 55 + height: var(--avatar-size-lg, 80px); 56 + border-radius: 50%; 57 + flex-shrink: 0; 58 + } 59 + .handle { 60 + width: 120px; 61 + height: 20px; 62 + margin-bottom: var(--space-xs); 63 + } 64 + .name { 65 + width: 80px; 66 + height: 14px; 67 + margin-bottom: var(--space-xs); 68 + } 69 + .stats { 70 + display: flex; 71 + gap: var(--space-sm); 72 + margin-bottom: var(--space-xs); 73 + } 74 + .stat { 75 + width: 70px; 76 + height: 14px; 77 + } 78 + .bio-line { 79 + height: 14px; 80 + margin-top: var(--space-xs); 81 + } 82 + .bio-line:first-of-type { 83 + width: 100%; 84 + } 85 + .bio-line:last-of-type { 86 + width: 60%; 87 + } 88 + .button { 89 + width: 100%; 90 + height: 40px; 91 + border-radius: 8px; 92 + margin-top: var(--space-sm); 93 + } 94 + @keyframes pulse { 95 + 0%, 100% { opacity: 0.4; } 96 + 50% { opacity: 1; } 97 + } 98 + `; 99 + 100 + render() { 101 + return html` 102 + <div class="top-row"> 103 + <div class="placeholder avatar"></div> 104 + <div class="right-column"> 105 + <div class="placeholder handle"></div> 106 + <div class="placeholder name"></div> 107 + <div class="stats"> 108 + <div class="placeholder stat"></div> 109 + <div class="placeholder stat"></div> 110 + <div class="placeholder stat"></div> 111 + </div> 112 + <div class="placeholder bio-line"></div> 113 + <div class="placeholder bio-line"></div> 114 + </div> 115 + </div> 116 + <div class="placeholder button"></div> 117 + `; 118 + } 119 + } 120 + 121 + customElements.define('grain-profile-header-skeleton', GrainProfileHeaderSkeleton); 122 + ``` 123 + 124 + **Step 2: Commit the new component** 125 + 126 + ```bash 127 + git add src/components/molecules/grain-profile-header-skeleton.js 128 + git commit -m "feat: add profile header skeleton component" 129 + ``` 130 + 131 + --- 132 + 133 + ### Task 2: Integrate Skeleton into Profile Page 134 + 135 + **Files:** 136 + - Modify: `src/components/pages/grain-profile.js:4` (add import) 137 + - Modify: `src/components/pages/grain-profile.js:171` (swap spinner for skeleton) 138 + 139 + **Step 1: Add import for skeleton component** 140 + 141 + In `src/components/pages/grain-profile.js`, add import after line 8 (after `grain-avatar-crop.js`): 142 + 143 + ```javascript 144 + import '../molecules/grain-profile-header-skeleton.js'; 145 + ``` 146 + 147 + **Step 2: Replace spinner with skeleton** 148 + 149 + In `src/components/pages/grain-profile.js`, change line 171 from: 150 + 151 + ```javascript 152 + ${this._loading ? html`<grain-spinner></grain-spinner>` : ''} 153 + ``` 154 + 155 + To: 156 + 157 + ```javascript 158 + ${this._loading ? html`<grain-profile-header-skeleton></grain-profile-header-skeleton>` : ''} 159 + ``` 160 + 161 + **Step 3: Commit the integration** 162 + 163 + ```bash 164 + git add src/components/pages/grain-profile.js 165 + git commit -m "feat: use skeleton loader for profile page" 166 + ``` 167 + 168 + --- 169 + 170 + ### Task 3: Manual Verification 171 + 172 + **Step 1: Test fresh profile load** 173 + 174 + 1. Run the dev server: `npm run dev` 175 + 2. Clear browser cache or open incognito 176 + 3. Navigate to a profile page (e.g., `/profile/somehandle`) 177 + 4. Observe: skeleton should appear with pulsing animation 178 + 5. Verify: no layout shift when profile data loads 179 + 180 + **Step 2: Test cached navigation** 181 + 182 + 1. Navigate away from profile 183 + 2. Navigate back to same profile 184 + 3. Verify: if cached, profile loads instantly (no skeleton) 185 + 4. Verify: if not cached, skeleton appears 186 + 187 + **Step 3: Verify responsive layout** 188 + 189 + 1. Test on mobile viewport (< 600px) 190 + 2. Test on desktop viewport (> 600px) 191 + 3. Verify: skeleton matches header padding in both views