docs: add alt overlay scroll fix plan

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

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

Changed files
+257
docs
+257
docs/plans/2026-01-04-alt-overlay-scroll-fix.md
··· 1 + # Alt Text Overlay Scroll Fix 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Fix alt text overlay position drifting when page scrolls by moving overlay rendering from the badge to the carousel. 6 + 7 + **Architecture:** Move overlay from `grain-alt-badge` (which uses `position: fixed` with JS positioning) to `grain-image-carousel` (which renders it inside the slide with `position: absolute; inset: 0`). The badge becomes a simple button that emits an event. 8 + 9 + **Tech Stack:** Lit, Web Components, CSS 10 + 11 + --- 12 + 13 + ### Task 1: Simplify grain-alt-badge to emit event 14 + 15 + **Files:** 16 + - Modify: `src/components/atoms/grain-alt-badge.js` 17 + 18 + **Step 1: Remove overlay state and scroll listener logic** 19 + 20 + Replace the entire file with: 21 + 22 + ```js 23 + import { LitElement, html, css } from 'lit'; 24 + 25 + export class GrainAltBadge extends LitElement { 26 + static properties = { 27 + alt: { type: String } 28 + }; 29 + 30 + static styles = css` 31 + :host { 32 + position: absolute; 33 + bottom: 8px; 34 + right: 8px; 35 + z-index: 2; 36 + } 37 + .badge { 38 + background: rgba(0, 0, 0, 0.7); 39 + color: white; 40 + font-size: 10px; 41 + font-weight: 600; 42 + padding: 2px 4px; 43 + border-radius: 4px; 44 + cursor: pointer; 45 + user-select: none; 46 + border: none; 47 + font-family: inherit; 48 + } 49 + .badge:hover { 50 + background: rgba(0, 0, 0, 0.85); 51 + } 52 + .badge:focus { 53 + outline: 2px solid white; 54 + outline-offset: 1px; 55 + } 56 + `; 57 + 58 + constructor() { 59 + super(); 60 + this.alt = ''; 61 + } 62 + 63 + #handleClick(e) { 64 + e.stopPropagation(); 65 + this.dispatchEvent(new CustomEvent('alt-click', { 66 + bubbles: true, 67 + composed: true, 68 + detail: { alt: this.alt } 69 + })); 70 + } 71 + 72 + render() { 73 + if (!this.alt) return null; 74 + 75 + return html` 76 + <button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button> 77 + `; 78 + } 79 + } 80 + 81 + customElements.define('grain-alt-badge', GrainAltBadge); 82 + ``` 83 + 84 + **Step 2: Verify badge still renders** 85 + 86 + Run the app, navigate to a gallery with alt text, confirm "ALT" button appears. 87 + 88 + --- 89 + 90 + ### Task 2: Add overlay state and styles to grain-image-carousel 91 + 92 + **Files:** 93 + - Modify: `src/components/organisms/grain-image-carousel.js` 94 + 95 + **Step 1: Add `_activeAltIndex` state property** 96 + 97 + In `static properties`, add: 98 + 99 + ```js 100 + static properties = { 101 + photos: { type: Array }, 102 + rkey: { type: String }, 103 + _currentIndex: { state: true }, 104 + _activeAltIndex: { state: true } 105 + }; 106 + ``` 107 + 108 + **Step 2: Initialize state in constructor** 109 + 110 + Add to constructor: 111 + 112 + ```js 113 + this._activeAltIndex = null; 114 + ``` 115 + 116 + **Step 3: Add overlay styles** 117 + 118 + Add to `static styles` (after `.nav-arrow-right`): 119 + 120 + ```css 121 + .alt-overlay { 122 + position: absolute; 123 + inset: 0; 124 + background: rgba(0, 0, 0, 0.75); 125 + color: white; 126 + padding: 16px; 127 + font-size: 14px; 128 + line-height: 1.5; 129 + overflow-y: auto; 130 + display: flex; 131 + align-items: center; 132 + justify-content: center; 133 + text-align: center; 134 + box-sizing: border-box; 135 + z-index: 3; 136 + cursor: pointer; 137 + } 138 + ``` 139 + 140 + --- 141 + 142 + ### Task 3: Add overlay event handlers to carousel 143 + 144 + **Files:** 145 + - Modify: `src/components/organisms/grain-image-carousel.js` 146 + 147 + **Step 1: Add handler for alt-click event** 148 + 149 + Add method: 150 + 151 + ```js 152 + #handleAltClick(e, index) { 153 + e.stopPropagation(); 154 + this._activeAltIndex = index; 155 + } 156 + ``` 157 + 158 + **Step 2: Add handler for overlay click (dismiss)** 159 + 160 + Add method: 161 + 162 + ```js 163 + #handleOverlayClick(e) { 164 + e.stopPropagation(); 165 + this._activeAltIndex = null; 166 + } 167 + ``` 168 + 169 + **Step 3: Dismiss overlay on slide change** 170 + 171 + Modify `#handleScroll` to clear overlay when swiping: 172 + 173 + ```js 174 + #handleScroll(e) { 175 + const carousel = e.target; 176 + const index = Math.round(carousel.scrollLeft / carousel.offsetWidth); 177 + if (index !== this._currentIndex) { 178 + this._currentIndex = index; 179 + this._activeAltIndex = null; 180 + } 181 + } 182 + ``` 183 + 184 + --- 185 + 186 + ### Task 4: Render overlay in slide template 187 + 188 + **Files:** 189 + - Modify: `src/components/organisms/grain-image-carousel.js` 190 + 191 + **Step 1: Update slide template in render()** 192 + 193 + Replace the slide mapping (lines 153-162) with: 194 + 195 + ```js 196 + ${this.photos.map((photo, index) => html` 197 + <div class="slide ${hasPortrait ? 'centered' : ''}"> 198 + <grain-image 199 + src=${this.#shouldLoad(index) ? photo.url : ''} 200 + alt=${photo.alt || ''} 201 + aspectRatio=${photo.aspectRatio || 1} 202 + style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 203 + ></grain-image> 204 + ${photo.alt ? html` 205 + <grain-alt-badge 206 + .alt=${photo.alt} 207 + @alt-click=${(e) => this.#handleAltClick(e, index)} 208 + ></grain-alt-badge> 209 + ` : ''} 210 + ${this._activeAltIndex === index ? html` 211 + <div class="alt-overlay" @click=${this.#handleOverlayClick}> 212 + ${photo.alt} 213 + </div> 214 + ` : ''} 215 + </div> 216 + `)} 217 + ``` 218 + 219 + --- 220 + 221 + ### Task 5: Manual testing 222 + 223 + **Step 1: Test overlay appears correctly** 224 + 225 + 1. Navigate to a gallery with alt text 226 + 2. Click "ALT" button 227 + 3. Confirm overlay appears covering the image 228 + 229 + **Step 2: Test overlay dismisses on click** 230 + 231 + 1. With overlay open, click the overlay 232 + 2. Confirm overlay closes 233 + 234 + **Step 3: Test overlay dismisses on swipe** 235 + 236 + 1. Open alt overlay on first image 237 + 2. Swipe to second image 238 + 3. Confirm overlay closes 239 + 240 + **Step 4: Test scroll behavior (the bug fix)** 241 + 242 + 1. Open alt overlay 243 + 2. Scroll the page up/down 244 + 3. Confirm overlay stays attached to the image (doesn't drift) 245 + 246 + --- 247 + 248 + ### Task 6: Commit 249 + 250 + ```bash 251 + git add src/components/atoms/grain-alt-badge.js src/components/organisms/grain-image-carousel.js 252 + git commit -m "fix: alt text overlay stays attached on page scroll 253 + 254 + Move overlay rendering from grain-alt-badge to grain-image-carousel. 255 + The overlay now uses position:absolute within the slide instead of 256 + position:fixed with JS positioning, so it naturally scrolls with content." 257 + ```