+257
docs/plans/2026-01-04-alt-overlay-scroll-fix.md
+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
+
```