WIP PWA for Grain

docs: add alt text feature implementation plan

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