tangled
alpha
login
or
join now
nichoth.com
/
grain-pwa
forked from
grain.social/grain-pwa
0
fork
atom
WIP PWA for Grain
0
fork
atom
overview
issues
pulls
pipelines
docs: add alt text feature implementation plan
chadtmiller.com
2 months ago
61ced206
e4d95bd0
+1046
1 changed file
expand all
collapse all
unified
split
docs
plans
2025-12-30-alt-text-feature.md
+1046
docs/plans/2025-12-30-alt-text-feature.md
···
1
1
+
# Alt Text Feature Implementation Plan
2
2
+
3
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
4
+
5
5
+
**Goal:** Add alt text input during gallery creation and display an ALT badge on images that have alt text.
6
6
+
7
7
+
**Architecture:** Two-step gallery creation flow (title/description → image descriptions), plus an ALT badge component that shows alt text in an overlay when clicked.
8
8
+
9
9
+
**Tech Stack:** Lit, CSS positioning, existing grain-icon component
10
10
+
11
11
+
---
12
12
+
13
13
+
### Task 1: Update Draft Gallery Service
14
14
+
15
15
+
**Files:**
16
16
+
- Modify: `src/services/draft-gallery.js`
17
17
+
18
18
+
**Step 1: Add updatePhotoAlt method**
19
19
+
20
20
+
Update the service to support setting alt text on individual photos:
21
21
+
22
22
+
```javascript
23
23
+
class DraftGalleryService {
24
24
+
#photos = [];
25
25
+
26
26
+
setPhotos(photos) {
27
27
+
// Ensure each photo has an alt property
28
28
+
this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' }));
29
29
+
}
30
30
+
31
31
+
getPhotos() {
32
32
+
return this.#photos;
33
33
+
}
34
34
+
35
35
+
updatePhotoAlt(index, alt) {
36
36
+
if (index >= 0 && index < this.#photos.length) {
37
37
+
this.#photos[index] = { ...this.#photos[index], alt };
38
38
+
}
39
39
+
}
40
40
+
41
41
+
clear() {
42
42
+
this.#photos = [];
43
43
+
}
44
44
+
45
45
+
get hasPhotos() {
46
46
+
return this.#photos.length > 0;
47
47
+
}
48
48
+
}
49
49
+
50
50
+
export const draftGallery = new DraftGalleryService();
51
51
+
```
52
52
+
53
53
+
**Step 2: Commit**
54
54
+
55
55
+
```bash
56
56
+
git add src/services/draft-gallery.js
57
57
+
git commit -m "feat: add alt text support to draft gallery service"
58
58
+
```
59
59
+
60
60
+
---
61
61
+
62
62
+
### Task 2: Create Image Descriptions Page
63
63
+
64
64
+
**Files:**
65
65
+
- Create: `src/components/pages/grain-image-descriptions.js`
66
66
+
67
67
+
**Step 1: Create the page component**
68
68
+
69
69
+
```javascript
70
70
+
import { LitElement, html, css } from 'lit';
71
71
+
import { router } from '../../router.js';
72
72
+
import { auth } from '../../services/auth.js';
73
73
+
import { draftGallery } from '../../services/draft-gallery.js';
74
74
+
import { parseTextToFacets } from '../../lib/richtext.js';
75
75
+
import { grainApi } from '../../services/grain-api.js';
76
76
+
import '../atoms/grain-icon.js';
77
77
+
import '../atoms/grain-button.js';
78
78
+
79
79
+
const UPLOAD_BLOB_MUTATION = `
80
80
+
mutation UploadBlob($data: String!, $mimeType: String!) {
81
81
+
uploadBlob(data: $data, mimeType: $mimeType) {
82
82
+
ref
83
83
+
mimeType
84
84
+
size
85
85
+
}
86
86
+
}
87
87
+
`;
88
88
+
89
89
+
const CREATE_PHOTO_MUTATION = `
90
90
+
mutation CreatePhoto($input: SocialGrainPhotoInput!) {
91
91
+
createSocialGrainPhoto(input: $input) {
92
92
+
uri
93
93
+
}
94
94
+
}
95
95
+
`;
96
96
+
97
97
+
const CREATE_GALLERY_MUTATION = `
98
98
+
mutation CreateGallery($input: SocialGrainGalleryInput!) {
99
99
+
createSocialGrainGallery(input: $input) {
100
100
+
uri
101
101
+
}
102
102
+
}
103
103
+
`;
104
104
+
105
105
+
const CREATE_GALLERY_ITEM_MUTATION = `
106
106
+
mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) {
107
107
+
createSocialGrainGalleryItem(input: $input) {
108
108
+
uri
109
109
+
}
110
110
+
}
111
111
+
`;
112
112
+
113
113
+
export class GrainImageDescriptions extends LitElement {
114
114
+
static properties = {
115
115
+
_photos: { state: true },
116
116
+
_title: { state: true },
117
117
+
_description: { state: true },
118
118
+
_posting: { state: true },
119
119
+
_error: { state: true }
120
120
+
};
121
121
+
122
122
+
static styles = css`
123
123
+
:host {
124
124
+
display: block;
125
125
+
width: 100%;
126
126
+
max-width: var(--feed-max-width);
127
127
+
min-height: 100%;
128
128
+
background: var(--color-bg-primary);
129
129
+
align-self: center;
130
130
+
}
131
131
+
.header {
132
132
+
display: flex;
133
133
+
align-items: center;
134
134
+
justify-content: space-between;
135
135
+
padding: var(--space-sm);
136
136
+
border-bottom: 1px solid var(--color-border);
137
137
+
}
138
138
+
.header-left {
139
139
+
display: flex;
140
140
+
align-items: center;
141
141
+
gap: var(--space-xs);
142
142
+
}
143
143
+
.back-button {
144
144
+
background: none;
145
145
+
border: none;
146
146
+
padding: 8px;
147
147
+
margin-left: -8px;
148
148
+
cursor: pointer;
149
149
+
color: var(--color-text-primary);
150
150
+
}
151
151
+
.header-title {
152
152
+
font-size: var(--font-size-md);
153
153
+
font-weight: 600;
154
154
+
}
155
155
+
.photo-list {
156
156
+
padding: var(--space-sm);
157
157
+
}
158
158
+
.photo-row {
159
159
+
display: flex;
160
160
+
gap: var(--space-sm);
161
161
+
margin-bottom: var(--space-md);
162
162
+
}
163
163
+
.photo-thumb {
164
164
+
flex-shrink: 0;
165
165
+
width: 80px;
166
166
+
height: 80px;
167
167
+
border-radius: 4px;
168
168
+
object-fit: cover;
169
169
+
}
170
170
+
.alt-input {
171
171
+
flex: 1;
172
172
+
display: flex;
173
173
+
flex-direction: column;
174
174
+
}
175
175
+
.alt-input textarea {
176
176
+
flex: 1;
177
177
+
min-height: 60px;
178
178
+
padding: var(--space-xs);
179
179
+
border: 1px solid var(--color-border);
180
180
+
border-radius: 4px;
181
181
+
font-family: inherit;
182
182
+
font-size: var(--font-size-sm);
183
183
+
resize: none;
184
184
+
background: var(--color-bg-primary);
185
185
+
color: var(--color-text-primary);
186
186
+
}
187
187
+
.alt-input textarea:focus {
188
188
+
outline: none;
189
189
+
border-color: var(--color-accent);
190
190
+
}
191
191
+
.alt-input textarea::placeholder {
192
192
+
color: var(--color-text-tertiary);
193
193
+
}
194
194
+
.char-count {
195
195
+
font-size: var(--font-size-xs);
196
196
+
color: var(--color-text-tertiary);
197
197
+
text-align: right;
198
198
+
margin-top: 4px;
199
199
+
}
200
200
+
.error {
201
201
+
color: #ff4444;
202
202
+
padding: var(--space-sm);
203
203
+
text-align: center;
204
204
+
}
205
205
+
`;
206
206
+
207
207
+
constructor() {
208
208
+
super();
209
209
+
this._photos = [];
210
210
+
this._title = '';
211
211
+
this._description = '';
212
212
+
this._posting = false;
213
213
+
this._error = null;
214
214
+
}
215
215
+
216
216
+
connectedCallback() {
217
217
+
super.connectedCallback();
218
218
+
219
219
+
if (!auth.isAuthenticated) {
220
220
+
router.replace('/');
221
221
+
return;
222
222
+
}
223
223
+
224
224
+
this._photos = draftGallery.getPhotos();
225
225
+
this._title = sessionStorage.getItem('draft_title') || '';
226
226
+
this._description = sessionStorage.getItem('draft_description') || '';
227
227
+
228
228
+
if (!this._photos.length) {
229
229
+
router.push('/');
230
230
+
}
231
231
+
}
232
232
+
233
233
+
#handleBack() {
234
234
+
router.push('/create');
235
235
+
}
236
236
+
237
237
+
#handleAltChange(index, e) {
238
238
+
const alt = e.target.value.slice(0, 1000);
239
239
+
draftGallery.updatePhotoAlt(index, alt);
240
240
+
this._photos = [...draftGallery.getPhotos()];
241
241
+
}
242
242
+
243
243
+
async #handlePost() {
244
244
+
if (this._posting) return;
245
245
+
246
246
+
this._posting = true;
247
247
+
this._error = null;
248
248
+
249
249
+
try {
250
250
+
const client = auth.getClient();
251
251
+
const now = new Date().toISOString();
252
252
+
253
253
+
const photoUris = [];
254
254
+
for (const photo of this._photos) {
255
255
+
const base64Data = photo.dataUrl.split(',')[1];
256
256
+
const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, {
257
257
+
data: base64Data,
258
258
+
mimeType: 'image/jpeg'
259
259
+
});
260
260
+
261
261
+
if (!uploadResult.uploadBlob) {
262
262
+
throw new Error('Failed to upload image');
263
263
+
}
264
264
+
265
265
+
const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, {
266
266
+
input: {
267
267
+
photo: {
268
268
+
$type: 'blob',
269
269
+
ref: { $link: uploadResult.uploadBlob.ref },
270
270
+
mimeType: uploadResult.uploadBlob.mimeType,
271
271
+
size: uploadResult.uploadBlob.size
272
272
+
},
273
273
+
aspectRatio: {
274
274
+
width: photo.width,
275
275
+
height: photo.height
276
276
+
},
277
277
+
...(photo.alt && { alt: photo.alt }),
278
278
+
createdAt: now
279
279
+
}
280
280
+
});
281
281
+
282
282
+
photoUris.push(photoResult.createSocialGrainPhoto.uri);
283
283
+
}
284
284
+
285
285
+
let facets = null;
286
286
+
if (this._description.trim()) {
287
287
+
const resolveHandle = async (handle) => grainApi.resolveHandle(handle);
288
288
+
const parsed = await parseTextToFacets(this._description.trim(), resolveHandle);
289
289
+
if (parsed.facets.length > 0) {
290
290
+
facets = parsed.facets;
291
291
+
}
292
292
+
}
293
293
+
294
294
+
const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, {
295
295
+
input: {
296
296
+
title: this._title.trim(),
297
297
+
...(this._description.trim() && { description: this._description.trim() }),
298
298
+
...(facets && { facets }),
299
299
+
createdAt: now
300
300
+
}
301
301
+
});
302
302
+
303
303
+
const galleryUri = galleryResult.createSocialGrainGallery.uri;
304
304
+
305
305
+
for (let i = 0; i < photoUris.length; i++) {
306
306
+
await client.mutate(CREATE_GALLERY_ITEM_MUTATION, {
307
307
+
input: {
308
308
+
gallery: galleryUri,
309
309
+
item: photoUris[i],
310
310
+
position: i,
311
311
+
createdAt: now
312
312
+
}
313
313
+
});
314
314
+
}
315
315
+
316
316
+
draftGallery.clear();
317
317
+
sessionStorage.removeItem('draft_title');
318
318
+
sessionStorage.removeItem('draft_description');
319
319
+
const rkey = galleryUri.split('/').pop();
320
320
+
router.push(`/profile/${auth.user.handle}/gallery/${rkey}`);
321
321
+
322
322
+
} catch (err) {
323
323
+
console.error('Failed to create gallery:', err);
324
324
+
this._error = err.message || 'Failed to create gallery. Please try again.';
325
325
+
} finally {
326
326
+
this._posting = false;
327
327
+
}
328
328
+
}
329
329
+
330
330
+
render() {
331
331
+
return html`
332
332
+
<div class="header">
333
333
+
<div class="header-left">
334
334
+
<button class="back-button" @click=${this.#handleBack}>
335
335
+
<grain-icon name="back" size="20"></grain-icon>
336
336
+
</button>
337
337
+
<span class="header-title">Add image descriptions</span>
338
338
+
</div>
339
339
+
<grain-button
340
340
+
?loading=${this._posting}
341
341
+
loadingText="Posting..."
342
342
+
@click=${this.#handlePost}
343
343
+
>Post</grain-button>
344
344
+
</div>
345
345
+
346
346
+
${this._error ? html`<p class="error">${this._error}</p>` : ''}
347
347
+
348
348
+
<div class="photo-list">
349
349
+
${this._photos.map((photo, i) => html`
350
350
+
<div class="photo-row">
351
351
+
<img class="photo-thumb" src=${photo.dataUrl} alt="Photo ${i + 1}">
352
352
+
<div class="alt-input">
353
353
+
<textarea
354
354
+
placeholder="Describe this image for people who can't see it"
355
355
+
.value=${photo.alt || ''}
356
356
+
@input=${(e) => this.#handleAltChange(i, e)}
357
357
+
></textarea>
358
358
+
<span class="char-count">${(photo.alt || '').length}/1000</span>
359
359
+
</div>
360
360
+
</div>
361
361
+
`)}
362
362
+
</div>
363
363
+
`;
364
364
+
}
365
365
+
}
366
366
+
367
367
+
customElements.define('grain-image-descriptions', GrainImageDescriptions);
368
368
+
```
369
369
+
370
370
+
**Step 2: Commit**
371
371
+
372
372
+
```bash
373
373
+
git add src/components/pages/grain-image-descriptions.js
374
374
+
git commit -m "feat: add image descriptions page for alt text entry"
375
375
+
```
376
376
+
377
377
+
---
378
378
+
379
379
+
### Task 3: Update Create Gallery Page
380
380
+
381
381
+
**Files:**
382
382
+
- Modify: `src/components/pages/grain-create-gallery.js`
383
383
+
384
384
+
**Step 1: Change Post button to Next and navigate to descriptions page**
385
385
+
386
386
+
Remove the posting logic (moved to descriptions page) and update the button:
387
387
+
388
388
+
Replace the `#handlePost` method with `#handleNext`:
389
389
+
390
390
+
```javascript
391
391
+
#handleNext() {
392
392
+
if (!this.#canProceed) return;
393
393
+
394
394
+
// Save title/description to sessionStorage for the next page
395
395
+
sessionStorage.setItem('draft_title', this._title);
396
396
+
sessionStorage.setItem('draft_description', this._description);
397
397
+
398
398
+
// Update draft with current photos (in case any were removed)
399
399
+
draftGallery.setPhotos(this._photos);
400
400
+
401
401
+
router.push('/create/descriptions');
402
402
+
}
403
403
+
```
404
404
+
405
405
+
Update `#canPost` to `#canProceed`:
406
406
+
407
407
+
```javascript
408
408
+
get #canProceed() {
409
409
+
return this._title.trim().length > 0 && this._photos.length > 0;
410
410
+
}
411
411
+
```
412
412
+
413
413
+
Remove the `_posting` and `_error` properties and their usage.
414
414
+
415
415
+
Remove the mutation constants (UPLOAD_BLOB_MUTATION, CREATE_PHOTO_MUTATION, CREATE_GALLERY_MUTATION, CREATE_GALLERY_ITEM_MUTATION).
416
416
+
417
417
+
Remove imports for `parseTextToFacets` and `grainApi`.
418
418
+
419
419
+
Update the button in render:
420
420
+
421
421
+
```javascript
422
422
+
<grain-button
423
423
+
?disabled=${!this.#canProceed}
424
424
+
@click=${this.#handleNext}
425
425
+
>Next</grain-button>
426
426
+
```
427
427
+
428
428
+
Remove the error display from render.
429
429
+
430
430
+
**Step 2: Full updated file**
431
431
+
432
432
+
```javascript
433
433
+
import { LitElement, html, css } from 'lit';
434
434
+
import { router } from '../../router.js';
435
435
+
import { auth } from '../../services/auth.js';
436
436
+
import { draftGallery } from '../../services/draft-gallery.js';
437
437
+
import '../atoms/grain-icon.js';
438
438
+
import '../atoms/grain-button.js';
439
439
+
import '../atoms/grain-input.js';
440
440
+
import '../atoms/grain-textarea.js';
441
441
+
import '../molecules/grain-form-field.js';
442
442
+
443
443
+
export class GrainCreateGallery extends LitElement {
444
444
+
static properties = {
445
445
+
_photos: { state: true },
446
446
+
_title: { state: true },
447
447
+
_description: { state: true }
448
448
+
};
449
449
+
450
450
+
static styles = css`
451
451
+
:host {
452
452
+
display: block;
453
453
+
width: 100%;
454
454
+
max-width: var(--feed-max-width);
455
455
+
min-height: 100%;
456
456
+
background: var(--color-bg-primary);
457
457
+
align-self: center;
458
458
+
}
459
459
+
.header {
460
460
+
display: flex;
461
461
+
align-items: center;
462
462
+
justify-content: space-between;
463
463
+
padding: var(--space-sm);
464
464
+
border-bottom: 1px solid var(--color-border);
465
465
+
}
466
466
+
.header-left {
467
467
+
display: flex;
468
468
+
align-items: center;
469
469
+
gap: var(--space-xs);
470
470
+
}
471
471
+
.back-button {
472
472
+
background: none;
473
473
+
border: none;
474
474
+
padding: 8px;
475
475
+
margin-left: -8px;
476
476
+
cursor: pointer;
477
477
+
color: var(--color-text-primary);
478
478
+
}
479
479
+
.header-title {
480
480
+
font-size: var(--font-size-md);
481
481
+
font-weight: 600;
482
482
+
}
483
483
+
.photo-strip {
484
484
+
display: flex;
485
485
+
gap: var(--space-xs);
486
486
+
padding: var(--space-sm);
487
487
+
overflow-x: auto;
488
488
+
border-bottom: 1px solid var(--color-border);
489
489
+
}
490
490
+
.photo-thumb {
491
491
+
position: relative;
492
492
+
flex-shrink: 0;
493
493
+
}
494
494
+
.photo-thumb img {
495
495
+
width: 80px;
496
496
+
height: 80px;
497
497
+
object-fit: cover;
498
498
+
border-radius: 4px;
499
499
+
}
500
500
+
.remove-photo {
501
501
+
position: absolute;
502
502
+
top: -6px;
503
503
+
right: -6px;
504
504
+
width: 20px;
505
505
+
height: 20px;
506
506
+
border-radius: 50%;
507
507
+
background: var(--color-text-primary);
508
508
+
color: var(--color-bg-primary);
509
509
+
border: none;
510
510
+
cursor: pointer;
511
511
+
font-size: 12px;
512
512
+
display: flex;
513
513
+
align-items: center;
514
514
+
justify-content: center;
515
515
+
}
516
516
+
.form {
517
517
+
padding: var(--space-sm);
518
518
+
}
519
519
+
`;
520
520
+
521
521
+
constructor() {
522
522
+
super();
523
523
+
this._photos = [];
524
524
+
this._title = '';
525
525
+
this._description = '';
526
526
+
}
527
527
+
528
528
+
connectedCallback() {
529
529
+
super.connectedCallback();
530
530
+
531
531
+
if (!auth.isAuthenticated) {
532
532
+
router.replace('/');
533
533
+
return;
534
534
+
}
535
535
+
536
536
+
this._photos = draftGallery.getPhotos();
537
537
+
538
538
+
// Restore title/description if returning from descriptions page
539
539
+
this._title = sessionStorage.getItem('draft_title') || '';
540
540
+
this._description = sessionStorage.getItem('draft_description') || '';
541
541
+
542
542
+
if (!this._photos.length) {
543
543
+
router.push('/');
544
544
+
}
545
545
+
}
546
546
+
547
547
+
#handleBack() {
548
548
+
if (confirm('Discard this gallery?')) {
549
549
+
draftGallery.clear();
550
550
+
sessionStorage.removeItem('draft_title');
551
551
+
sessionStorage.removeItem('draft_description');
552
552
+
history.back();
553
553
+
}
554
554
+
}
555
555
+
556
556
+
#removePhoto(index) {
557
557
+
this._photos = this._photos.filter((_, i) => i !== index);
558
558
+
draftGallery.setPhotos(this._photos);
559
559
+
if (this._photos.length === 0) {
560
560
+
draftGallery.clear();
561
561
+
sessionStorage.removeItem('draft_title');
562
562
+
sessionStorage.removeItem('draft_description');
563
563
+
router.push('/');
564
564
+
}
565
565
+
}
566
566
+
567
567
+
#handleTitleChange(e) {
568
568
+
this._title = e.detail.value.slice(0, 100);
569
569
+
}
570
570
+
571
571
+
#handleDescriptionChange(e) {
572
572
+
this._description = e.detail.value.slice(0, 1000);
573
573
+
}
574
574
+
575
575
+
get #canProceed() {
576
576
+
return this._title.trim().length > 0 && this._photos.length > 0;
577
577
+
}
578
578
+
579
579
+
#handleNext() {
580
580
+
if (!this.#canProceed) return;
581
581
+
582
582
+
sessionStorage.setItem('draft_title', this._title);
583
583
+
sessionStorage.setItem('draft_description', this._description);
584
584
+
draftGallery.setPhotos(this._photos);
585
585
+
586
586
+
router.push('/create/descriptions');
587
587
+
}
588
588
+
589
589
+
render() {
590
590
+
return html`
591
591
+
<div class="header">
592
592
+
<div class="header-left">
593
593
+
<button class="back-button" @click=${this.#handleBack}>
594
594
+
<grain-icon name="back" size="20"></grain-icon>
595
595
+
</button>
596
596
+
<span class="header-title">Create a gallery</span>
597
597
+
</div>
598
598
+
<grain-button
599
599
+
?disabled=${!this.#canProceed}
600
600
+
@click=${this.#handleNext}
601
601
+
>Next</grain-button>
602
602
+
</div>
603
603
+
604
604
+
<div class="photo-strip">
605
605
+
${this._photos.map((photo, i) => html`
606
606
+
<div class="photo-thumb">
607
607
+
<img src=${photo.dataUrl} alt="Photo ${i + 1}">
608
608
+
<button class="remove-photo" @click=${() => this.#removePhoto(i)}>x</button>
609
609
+
</div>
610
610
+
`)}
611
611
+
</div>
612
612
+
613
613
+
<div class="form">
614
614
+
<grain-form-field .value=${this._title} .maxlength=${100}>
615
615
+
<grain-input
616
616
+
placeholder="Add a title..."
617
617
+
.value=${this._title}
618
618
+
@input=${this.#handleTitleChange}
619
619
+
></grain-input>
620
620
+
</grain-form-field>
621
621
+
622
622
+
<grain-form-field .value=${this._description} .maxlength=${1000}>
623
623
+
<grain-textarea
624
624
+
placeholder="Add a description (optional)..."
625
625
+
.value=${this._description}
626
626
+
.maxlength=${1000}
627
627
+
@input=${this.#handleDescriptionChange}
628
628
+
></grain-textarea>
629
629
+
</grain-form-field>
630
630
+
</div>
631
631
+
`;
632
632
+
}
633
633
+
}
634
634
+
635
635
+
customElements.define('grain-create-gallery', GrainCreateGallery);
636
636
+
```
637
637
+
638
638
+
**Step 3: Commit**
639
639
+
640
640
+
```bash
641
641
+
git add src/components/pages/grain-create-gallery.js
642
642
+
git commit -m "refactor: change create gallery to two-step flow with Next button"
643
643
+
```
644
644
+
645
645
+
---
646
646
+
647
647
+
### Task 4: Register Route
648
648
+
649
649
+
**Files:**
650
650
+
- Modify: `src/components/pages/grain-app.js`
651
651
+
652
652
+
**Step 1: Import the new page component**
653
653
+
654
654
+
Add after the other page imports:
655
655
+
656
656
+
```javascript
657
657
+
import './grain-image-descriptions.js';
658
658
+
```
659
659
+
660
660
+
**Step 2: Register the route**
661
661
+
662
662
+
Add after `.register('/create', 'grain-create-gallery')`:
663
663
+
664
664
+
```javascript
665
665
+
.register('/create/descriptions', 'grain-image-descriptions')
666
666
+
```
667
667
+
668
668
+
**Step 3: Commit**
669
669
+
670
670
+
```bash
671
671
+
git add src/components/pages/grain-app.js
672
672
+
git commit -m "feat: add route for image descriptions page"
673
673
+
```
674
674
+
675
675
+
---
676
676
+
677
677
+
### Task 5: Create ALT Badge Component
678
678
+
679
679
+
**Files:**
680
680
+
- Create: `src/components/atoms/grain-alt-badge.js`
681
681
+
682
682
+
**Step 1: Create the badge component with overlay functionality**
683
683
+
684
684
+
```javascript
685
685
+
import { LitElement, html, css } from 'lit';
686
686
+
687
687
+
export class GrainAltBadge extends LitElement {
688
688
+
static properties = {
689
689
+
alt: { type: String },
690
690
+
_showOverlay: { state: true }
691
691
+
};
692
692
+
693
693
+
static styles = css`
694
694
+
:host {
695
695
+
position: absolute;
696
696
+
bottom: 8px;
697
697
+
right: 8px;
698
698
+
z-index: 2;
699
699
+
}
700
700
+
.badge {
701
701
+
background: rgba(0, 0, 0, 0.7);
702
702
+
color: white;
703
703
+
font-size: 10px;
704
704
+
font-weight: 600;
705
705
+
padding: 2px 4px;
706
706
+
border-radius: 4px;
707
707
+
cursor: pointer;
708
708
+
user-select: none;
709
709
+
}
710
710
+
.badge:hover {
711
711
+
background: rgba(0, 0, 0, 0.85);
712
712
+
}
713
713
+
.overlay {
714
714
+
position: fixed;
715
715
+
bottom: 0;
716
716
+
left: 0;
717
717
+
right: 0;
718
718
+
background: rgba(0, 0, 0, 0.8);
719
719
+
color: white;
720
720
+
padding: var(--space-sm);
721
721
+
font-size: var(--font-size-sm);
722
722
+
line-height: 1.4;
723
723
+
max-height: 40vh;
724
724
+
overflow-y: auto;
725
725
+
z-index: 100;
726
726
+
}
727
727
+
`;
728
728
+
729
729
+
constructor() {
730
730
+
super();
731
731
+
this.alt = '';
732
732
+
this._showOverlay = false;
733
733
+
}
734
734
+
735
735
+
#handleClick(e) {
736
736
+
e.stopPropagation();
737
737
+
this._showOverlay = !this._showOverlay;
738
738
+
}
739
739
+
740
740
+
#handleOverlayClick(e) {
741
741
+
e.stopPropagation();
742
742
+
this._showOverlay = false;
743
743
+
}
744
744
+
745
745
+
render() {
746
746
+
if (!this.alt) return null;
747
747
+
748
748
+
return html`
749
749
+
<span class="badge" @click=${this.#handleClick}>ALT</span>
750
750
+
${this._showOverlay ? html`
751
751
+
<div class="overlay" @click=${this.#handleOverlayClick}>
752
752
+
${this.alt}
753
753
+
</div>
754
754
+
` : ''}
755
755
+
`;
756
756
+
}
757
757
+
}
758
758
+
759
759
+
customElements.define('grain-alt-badge', GrainAltBadge);
760
760
+
```
761
761
+
762
762
+
**Step 2: Commit**
763
763
+
764
764
+
```bash
765
765
+
git add src/components/atoms/grain-alt-badge.js
766
766
+
git commit -m "feat: add ALT badge component with overlay"
767
767
+
```
768
768
+
769
769
+
---
770
770
+
771
771
+
### Task 6: Add ALT Badge to Carousel
772
772
+
773
773
+
**Files:**
774
774
+
- Modify: `src/components/organisms/grain-image-carousel.js`
775
775
+
776
776
+
**Step 1: Import the badge component**
777
777
+
778
778
+
Add after the other imports:
779
779
+
780
780
+
```javascript
781
781
+
import '../atoms/grain-alt-badge.js';
782
782
+
```
783
783
+
784
784
+
**Step 2: Add styles for slide positioning**
785
785
+
786
786
+
Add to the `.slide` rule to enable absolute positioning of badge:
787
787
+
788
788
+
```css
789
789
+
.slide {
790
790
+
flex: 0 0 100%;
791
791
+
scroll-snap-align: start;
792
792
+
position: relative;
793
793
+
}
794
794
+
```
795
795
+
796
796
+
**Step 3: Add badge to each slide**
797
797
+
798
798
+
Update the slide rendering to include the badge:
799
799
+
800
800
+
```javascript
801
801
+
${this.photos.map((photo, index) => html`
802
802
+
<div class="slide ${hasPortrait ? 'centered' : ''}">
803
803
+
<grain-image
804
804
+
src=${this.#shouldLoad(index) ? photo.url : ''}
805
805
+
alt=${photo.alt || ''}
806
806
+
aspectRatio=${photo.aspectRatio || 1}
807
807
+
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
808
808
+
></grain-image>
809
809
+
${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''}
810
810
+
</div>
811
811
+
`)}
812
812
+
```
813
813
+
814
814
+
**Step 4: Full updated file**
815
815
+
816
816
+
```javascript
817
817
+
import { LitElement, html, css } from 'lit';
818
818
+
import '../atoms/grain-image.js';
819
819
+
import '../atoms/grain-icon.js';
820
820
+
import '../atoms/grain-alt-badge.js';
821
821
+
import '../molecules/grain-carousel-dots.js';
822
822
+
823
823
+
export class GrainImageCarousel extends LitElement {
824
824
+
static properties = {
825
825
+
photos: { type: Array },
826
826
+
rkey: { type: String },
827
827
+
_currentIndex: { state: true }
828
828
+
};
829
829
+
830
830
+
static styles = css`
831
831
+
:host {
832
832
+
display: block;
833
833
+
position: relative;
834
834
+
}
835
835
+
.carousel {
836
836
+
display: flex;
837
837
+
overflow-x: auto;
838
838
+
scroll-snap-type: x mandatory;
839
839
+
scrollbar-width: none;
840
840
+
-ms-overflow-style: none;
841
841
+
}
842
842
+
.carousel::-webkit-scrollbar {
843
843
+
display: none;
844
844
+
}
845
845
+
.slide {
846
846
+
flex: 0 0 100%;
847
847
+
scroll-snap-align: start;
848
848
+
position: relative;
849
849
+
}
850
850
+
.slide.centered {
851
851
+
display: flex;
852
852
+
align-items: center;
853
853
+
justify-content: center;
854
854
+
}
855
855
+
.slide.centered grain-image {
856
856
+
width: 100%;
857
857
+
}
858
858
+
.dots {
859
859
+
position: absolute;
860
860
+
bottom: 0;
861
861
+
left: 0;
862
862
+
right: 0;
863
863
+
}
864
864
+
.nav-arrow {
865
865
+
position: absolute;
866
866
+
top: 50%;
867
867
+
transform: translateY(-50%);
868
868
+
width: 24px;
869
869
+
height: 24px;
870
870
+
border-radius: 50%;
871
871
+
border: none;
872
872
+
background: rgba(255, 255, 255, 0.7);
873
873
+
color: rgba(120, 100, 90, 1);
874
874
+
cursor: pointer;
875
875
+
display: flex;
876
876
+
align-items: center;
877
877
+
justify-content: center;
878
878
+
padding: 0;
879
879
+
z-index: 1;
880
880
+
}
881
881
+
.nav-arrow:hover {
882
882
+
background: rgba(255, 255, 255, 1);
883
883
+
}
884
884
+
.nav-arrow:focus {
885
885
+
outline: none;
886
886
+
}
887
887
+
.nav-arrow:focus-visible {
888
888
+
outline: 2px solid rgba(120, 100, 90, 0.5);
889
889
+
outline-offset: 2px;
890
890
+
}
891
891
+
.nav-arrow-left {
892
892
+
left: 8px;
893
893
+
}
894
894
+
.nav-arrow-right {
895
895
+
right: 8px;
896
896
+
}
897
897
+
`;
898
898
+
899
899
+
constructor() {
900
900
+
super();
901
901
+
this.photos = [];
902
902
+
this._currentIndex = 0;
903
903
+
}
904
904
+
905
905
+
get #hasPortrait() {
906
906
+
return this.photos.some(photo => (photo.aspectRatio || 1) < 1);
907
907
+
}
908
908
+
909
909
+
get #minAspectRatio() {
910
910
+
if (!this.photos.length) return 1;
911
911
+
return Math.min(...this.photos.map(photo => photo.aspectRatio || 1));
912
912
+
}
913
913
+
914
914
+
#handleScroll(e) {
915
915
+
const carousel = e.target;
916
916
+
const index = Math.round(carousel.scrollLeft / carousel.offsetWidth);
917
917
+
if (index !== this._currentIndex) {
918
918
+
this._currentIndex = index;
919
919
+
}
920
920
+
}
921
921
+
922
922
+
#goToPrevious(e) {
923
923
+
e.stopPropagation();
924
924
+
if (this._currentIndex > 0) {
925
925
+
const carousel = this.shadowRoot.querySelector('.carousel');
926
926
+
const slides = carousel.querySelectorAll('.slide');
927
927
+
slides[this._currentIndex - 1].scrollIntoView({
928
928
+
behavior: 'smooth',
929
929
+
block: 'nearest',
930
930
+
inline: 'start'
931
931
+
});
932
932
+
}
933
933
+
}
934
934
+
935
935
+
#goToNext(e) {
936
936
+
e.stopPropagation();
937
937
+
if (this._currentIndex < this.photos.length - 1) {
938
938
+
const carousel = this.shadowRoot.querySelector('.carousel');
939
939
+
const slides = carousel.querySelectorAll('.slide');
940
940
+
slides[this._currentIndex + 1].scrollIntoView({
941
941
+
behavior: 'smooth',
942
942
+
block: 'nearest',
943
943
+
inline: 'start'
944
944
+
});
945
945
+
}
946
946
+
}
947
947
+
948
948
+
#shouldLoad(index) {
949
949
+
return Math.abs(index - this._currentIndex) <= 1;
950
950
+
}
951
951
+
952
952
+
getCurrentPhoto() {
953
953
+
return this.photos[this._currentIndex] || null;
954
954
+
}
955
955
+
956
956
+
render() {
957
957
+
const hasPortrait = this.#hasPortrait;
958
958
+
const minAspectRatio = this.#minAspectRatio;
959
959
+
const carouselStyle = hasPortrait
960
960
+
? `aspect-ratio: ${minAspectRatio};`
961
961
+
: '';
962
962
+
963
963
+
const showLeftArrow = this.photos.length > 1 && this._currentIndex > 0;
964
964
+
const showRightArrow = this.photos.length > 1 && this._currentIndex < this.photos.length - 1;
965
965
+
966
966
+
return html`
967
967
+
<div class="carousel" style=${carouselStyle} @scroll=${this.#handleScroll}>
968
968
+
${this.photos.map((photo, index) => html`
969
969
+
<div class="slide ${hasPortrait ? 'centered' : ''}">
970
970
+
<grain-image
971
971
+
src=${this.#shouldLoad(index) ? photo.url : ''}
972
972
+
alt=${photo.alt || ''}
973
973
+
aspectRatio=${photo.aspectRatio || 1}
974
974
+
style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''}
975
975
+
></grain-image>
976
976
+
${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''}
977
977
+
</div>
978
978
+
`)}
979
979
+
</div>
980
980
+
${showLeftArrow ? html`
981
981
+
<button class="nav-arrow nav-arrow-left" @click=${this.#goToPrevious} aria-label="Previous image">
982
982
+
<grain-icon name="chevronLeft" size="12"></grain-icon>
983
983
+
</button>
984
984
+
` : ''}
985
985
+
${showRightArrow ? html`
986
986
+
<button class="nav-arrow nav-arrow-right" @click=${this.#goToNext} aria-label="Next image">
987
987
+
<grain-icon name="chevronRight" size="12"></grain-icon>
988
988
+
</button>
989
989
+
` : ''}
990
990
+
${this.photos.length > 1 ? html`
991
991
+
<div class="dots">
992
992
+
<grain-carousel-dots
993
993
+
total=${this.photos.length}
994
994
+
current=${this._currentIndex}
995
995
+
></grain-carousel-dots>
996
996
+
</div>
997
997
+
` : ''}
998
998
+
`;
999
999
+
}
1000
1000
+
}
1001
1001
+
1002
1002
+
customElements.define('grain-image-carousel', GrainImageCarousel);
1003
1003
+
```
1004
1004
+
1005
1005
+
**Step 5: Commit**
1006
1006
+
1007
1007
+
```bash
1008
1008
+
git add src/components/organisms/grain-image-carousel.js
1009
1009
+
git commit -m "feat: add ALT badge to carousel images"
1010
1010
+
```
1011
1011
+
1012
1012
+
---
1013
1013
+
1014
1014
+
### Task 7: Manual Testing
1015
1015
+
1016
1016
+
**Step 1: Test gallery creation flow**
1017
1017
+
1018
1018
+
1. Click + button in nav to select photos
1019
1019
+
2. Enter title and description on first screen
1020
1020
+
3. Click "Next" - should navigate to image descriptions page
1021
1021
+
4. Verify photos appear with text areas
1022
1022
+
5. Add alt text to one or more images
1023
1023
+
6. Click "Post" - should create gallery and navigate to it
1024
1024
+
1025
1025
+
**Step 2: Test ALT badge display**
1026
1026
+
1027
1027
+
1. Navigate to a gallery with alt text (the one you just created)
1028
1028
+
2. Verify "ALT" badge appears in bottom right of images that have alt text
1029
1029
+
3. Click the badge - overlay should appear at bottom with alt text
1030
1030
+
4. Click overlay or elsewhere - overlay should dismiss
1031
1031
+
5. Verify badge does NOT appear on images without alt text
1032
1032
+
1033
1033
+
**Step 3: Test edge cases**
1034
1034
+
1035
1035
+
- Back button on descriptions page returns to create page with data preserved
1036
1036
+
- Back button on create page with "Discard" clears everything
1037
1037
+
- Single image gallery works
1038
1038
+
- Multi-image gallery works
1039
1039
+
- Very long alt text (up to 1000 chars) works
1040
1040
+
1041
1041
+
**Step 4: Final commit if any fixes needed**
1042
1042
+
1043
1043
+
```bash
1044
1044
+
git add -A
1045
1045
+
git commit -m "fix: alt text feature refinements"
1046
1046
+
```