feat: add alt text support for gallery images

- Two-step gallery creation flow (title/description → image descriptions)
- New image descriptions page with grain-textarea for alt text entry
- ALT badge component displayed on images with alt text
- Clicking badge shows overlay with alt text over the image
- Overlay dismisses on click or carousel scroll
- Keyboard accessible (button element with aria-label)

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

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

+138
src/components/atoms/grain-alt-badge.js
···
··· 1 + import { LitElement, html, css } from 'lit'; 2 + 3 + export class GrainAltBadge extends LitElement { 4 + static properties = { 5 + alt: { type: String }, 6 + _showOverlay: { state: true } 7 + }; 8 + 9 + static styles = css` 10 + :host { 11 + position: absolute; 12 + bottom: 8px; 13 + right: 8px; 14 + z-index: 2; 15 + } 16 + .badge { 17 + background: rgba(0, 0, 0, 0.7); 18 + color: white; 19 + font-size: 10px; 20 + font-weight: 600; 21 + padding: 2px 4px; 22 + border-radius: 4px; 23 + cursor: pointer; 24 + user-select: none; 25 + border: none; 26 + font-family: inherit; 27 + } 28 + .badge:hover { 29 + background: rgba(0, 0, 0, 0.85); 30 + } 31 + .badge:focus { 32 + outline: 2px solid white; 33 + outline-offset: 1px; 34 + } 35 + .overlay { 36 + position: absolute; 37 + bottom: 8px; 38 + right: 8px; 39 + left: -8px; 40 + top: -8px; 41 + transform: translate(-100%, -100%); 42 + transform: none; 43 + bottom: 0; 44 + right: 0; 45 + left: 0; 46 + top: 0; 47 + position: fixed; 48 + background: rgba(0, 0, 0, 0.75); 49 + color: white; 50 + padding: 16px; 51 + font-size: 14px; 52 + line-height: 1.5; 53 + overflow-y: auto; 54 + display: flex; 55 + align-items: center; 56 + justify-content: center; 57 + text-align: center; 58 + box-sizing: border-box; 59 + } 60 + `; 61 + 62 + #scrollHandler = null; 63 + #carousel = null; 64 + 65 + constructor() { 66 + super(); 67 + this.alt = ''; 68 + this._showOverlay = false; 69 + } 70 + 71 + disconnectedCallback() { 72 + super.disconnectedCallback(); 73 + this.#removeScrollListener(); 74 + } 75 + 76 + #handleClick(e) { 77 + e.stopPropagation(); 78 + this._showOverlay = !this._showOverlay; 79 + } 80 + 81 + #handleOverlayClick(e) { 82 + e.stopPropagation(); 83 + this._showOverlay = false; 84 + } 85 + 86 + #removeScrollListener() { 87 + if (this.#scrollHandler && this.#carousel) { 88 + this.#carousel.removeEventListener('scroll', this.#scrollHandler); 89 + this.#scrollHandler = null; 90 + this.#carousel = null; 91 + } 92 + } 93 + 94 + updated(changedProperties) { 95 + if (changedProperties.has('_showOverlay')) { 96 + if (this._showOverlay) { 97 + // Position overlay to cover the parent slide 98 + const slide = this.closest('.slide'); 99 + if (slide) { 100 + const rect = slide.getBoundingClientRect(); 101 + const overlay = this.shadowRoot.querySelector('.overlay'); 102 + if (overlay) { 103 + overlay.style.top = `${rect.top}px`; 104 + overlay.style.left = `${rect.left}px`; 105 + overlay.style.width = `${rect.width}px`; 106 + overlay.style.height = `${rect.height}px`; 107 + } 108 + 109 + // Listen for carousel scroll to dismiss overlay 110 + this.#carousel = slide.parentElement; 111 + if (this.#carousel) { 112 + this.#scrollHandler = () => { 113 + this._showOverlay = false; 114 + }; 115 + this.#carousel.addEventListener('scroll', this.#scrollHandler, { passive: true }); 116 + } 117 + } 118 + } else { 119 + this.#removeScrollListener(); 120 + } 121 + } 122 + } 123 + 124 + render() { 125 + if (!this.alt) return null; 126 + 127 + return html` 128 + <button class="badge" @click=${this.#handleClick} aria-label="View image description">ALT</button> 129 + ${this._showOverlay ? html` 130 + <div class="overlay" @click=${this.#handleOverlayClick}> 131 + ${this.alt} 132 + </div> 133 + ` : ''} 134 + `; 135 + } 136 + } 137 + 138 + customElements.define('grain-alt-badge', GrainAltBadge);
+3
src/components/organisms/grain-image-carousel.js
··· 1 import { LitElement, html, css } from 'lit'; 2 import '../atoms/grain-image.js'; 3 import '../atoms/grain-icon.js'; 4 import '../molecules/grain-carousel-dots.js'; 5 6 export class GrainImageCarousel extends LitElement { ··· 28 .slide { 29 flex: 0 0 100%; 30 scroll-snap-align: start; 31 } 32 .slide.centered { 33 display: flex; ··· 156 aspectRatio=${photo.aspectRatio || 1} 157 style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 158 ></grain-image> 159 </div> 160 `)} 161 </div>
··· 1 import { LitElement, html, css } from 'lit'; 2 import '../atoms/grain-image.js'; 3 import '../atoms/grain-icon.js'; 4 + import '../atoms/grain-alt-badge.js'; 5 import '../molecules/grain-carousel-dots.js'; 6 7 export class GrainImageCarousel extends LitElement { ··· 29 .slide { 30 flex: 0 0 100%; 31 scroll-snap-align: start; 32 + position: relative; 33 } 34 .slide.centered { 35 display: flex; ··· 158 aspectRatio=${photo.aspectRatio || 1} 159 style=${index === 0 && this.rkey ? `view-transition-name: gallery-hero-${this.rkey};` : ''} 160 ></grain-image> 161 + ${photo.alt ? html`<grain-alt-badge .alt=${photo.alt}></grain-alt-badge>` : ''} 162 </div> 163 `)} 164 </div>
+2
src/components/pages/grain-app.js
··· 10 import './grain-settings.js'; 11 import './grain-edit-profile.js'; 12 import './grain-create-gallery.js'; 13 import './grain-explore.js'; 14 import './grain-notifications.js'; 15 import './grain-terms.js'; ··· 61 .register('/legal/privacy', 'grain-privacy') 62 .register('/legal/copyright', 'grain-copyright') 63 .register('/create', 'grain-create-gallery') 64 .register('/explore', 'grain-explore') 65 .register('/notifications', 'grain-notifications') 66 .register('*', 'grain-timeline')
··· 10 import './grain-settings.js'; 11 import './grain-edit-profile.js'; 12 import './grain-create-gallery.js'; 13 + import './grain-image-descriptions.js'; 14 import './grain-explore.js'; 15 import './grain-notifications.js'; 16 import './grain-terms.js'; ··· 62 .register('/legal/privacy', 'grain-privacy') 63 .register('/legal/copyright', 'grain-copyright') 64 .register('/create', 'grain-create-gallery') 65 + .register('/create/descriptions', 'grain-image-descriptions') 66 .register('/explore', 'grain-explore') 67 .register('/notifications', 'grain-notifications') 68 .register('*', 'grain-timeline')
+22 -143
src/components/pages/grain-create-gallery.js
··· 2 import { router } from '../../router.js'; 3 import { auth } from '../../services/auth.js'; 4 import { draftGallery } from '../../services/draft-gallery.js'; 5 - import { parseTextToFacets } from '../../lib/richtext.js'; 6 - import { grainApi } from '../../services/grain-api.js'; 7 import '../atoms/grain-icon.js'; 8 import '../atoms/grain-button.js'; 9 import '../atoms/grain-input.js'; 10 import '../atoms/grain-textarea.js'; 11 import '../molecules/grain-form-field.js'; 12 13 - const UPLOAD_BLOB_MUTATION = ` 14 - mutation UploadBlob($data: String!, $mimeType: String!) { 15 - uploadBlob(data: $data, mimeType: $mimeType) { 16 - ref 17 - mimeType 18 - size 19 - } 20 - } 21 - `; 22 - 23 - const CREATE_PHOTO_MUTATION = ` 24 - mutation CreatePhoto($input: SocialGrainPhotoInput!) { 25 - createSocialGrainPhoto(input: $input) { 26 - uri 27 - } 28 - } 29 - `; 30 - 31 - const CREATE_GALLERY_MUTATION = ` 32 - mutation CreateGallery($input: SocialGrainGalleryInput!) { 33 - createSocialGrainGallery(input: $input) { 34 - uri 35 - } 36 - } 37 - `; 38 - 39 - const CREATE_GALLERY_ITEM_MUTATION = ` 40 - mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) { 41 - createSocialGrainGalleryItem(input: $input) { 42 - uri 43 - } 44 - } 45 - `; 46 - 47 export class GrainCreateGallery extends LitElement { 48 static properties = { 49 _photos: { state: true }, 50 _title: { state: true }, 51 - _description: { state: true }, 52 - _posting: { state: true }, 53 - _error: { state: true } 54 }; 55 56 static styles = css` ··· 122 .form { 123 padding: var(--space-sm); 124 } 125 - .error { 126 - color: #ff4444; 127 - padding: var(--space-sm); 128 - text-align: center; 129 - } 130 `; 131 132 constructor() { ··· 134 this._photos = []; 135 this._title = ''; 136 this._description = ''; 137 - this._posting = false; 138 - this._error = null; 139 } 140 141 connectedCallback() { 142 super.connectedCallback(); 143 144 - // Redirect to timeline if not authenticated 145 if (!auth.isAuthenticated) { 146 router.replace('/'); 147 return; 148 } 149 150 this._photos = draftGallery.getPhotos(); 151 if (!this._photos.length) { 152 router.push('/'); 153 } ··· 156 #handleBack() { 157 if (confirm('Discard this gallery?')) { 158 draftGallery.clear(); 159 history.back(); 160 } 161 } 162 163 #removePhoto(index) { 164 this._photos = this._photos.filter((_, i) => i !== index); 165 if (this._photos.length === 0) { 166 draftGallery.clear(); 167 router.push('/'); 168 } 169 } ··· 176 this._description = e.detail.value.slice(0, 1000); 177 } 178 179 - get #canPost() { 180 - return this._title.trim().length > 0 && this._photos.length > 0 && !this._posting; 181 } 182 183 - async #handlePost() { 184 - if (!this.#canPost) return; 185 - 186 - this._posting = true; 187 - this._error = null; 188 189 - try { 190 - const client = auth.getClient(); 191 - const now = new Date().toISOString(); 192 193 - // Upload photos and create photo records 194 - const photoUris = []; 195 - for (const photo of this._photos) { 196 - // Upload blob 197 - const base64Data = photo.dataUrl.split(',')[1]; 198 - const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 199 - data: base64Data, 200 - mimeType: 'image/jpeg' 201 - }); 202 - 203 - if (!uploadResult.uploadBlob) { 204 - throw new Error('Failed to upload image'); 205 - } 206 - 207 - // Create photo record 208 - const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, { 209 - input: { 210 - photo: { 211 - $type: 'blob', 212 - ref: { $link: uploadResult.uploadBlob.ref }, 213 - mimeType: uploadResult.uploadBlob.mimeType, 214 - size: uploadResult.uploadBlob.size 215 - }, 216 - aspectRatio: { 217 - width: photo.width, 218 - height: photo.height 219 - }, 220 - createdAt: now 221 - } 222 - }); 223 - 224 - photoUris.push(photoResult.createSocialGrainPhoto.uri); 225 - } 226 - 227 - // Parse description for facets 228 - let facets = null; 229 - if (this._description.trim()) { 230 - const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 231 - const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); 232 - if (parsed.facets.length > 0) { 233 - facets = parsed.facets; 234 - } 235 - } 236 - 237 - // Create gallery record 238 - const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 239 - input: { 240 - title: this._title.trim(), 241 - ...(this._description.trim() && { description: this._description.trim() }), 242 - ...(facets && { facets }), 243 - createdAt: now 244 - } 245 - }); 246 - 247 - const galleryUri = galleryResult.createSocialGrainGallery.uri; 248 - 249 - // Create gallery items linking photos to gallery 250 - for (let i = 0; i < photoUris.length; i++) { 251 - await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { 252 - input: { 253 - gallery: galleryUri, 254 - item: photoUris[i], 255 - position: i, 256 - createdAt: now 257 - } 258 - }); 259 - } 260 - 261 - // Clear draft and navigate to new gallery 262 - draftGallery.clear(); 263 - const rkey = galleryUri.split('/').pop(); 264 - router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); 265 - 266 - } catch (err) { 267 - console.error('Failed to create gallery:', err); 268 - this._error = err.message || 'Failed to create gallery. Please try again.'; 269 - } finally { 270 - this._posting = false; 271 - } 272 } 273 274 render() { ··· 281 <span class="header-title">Create a gallery</span> 282 </div> 283 <grain-button 284 - ?disabled=${!this.#canPost} 285 - ?loading=${this._posting} 286 - loadingText="Posting..." 287 - @click=${this.#handlePost} 288 - >Post</grain-button> 289 </div> 290 291 <div class="photo-strip"> ··· 296 </div> 297 `)} 298 </div> 299 - 300 - ${this._error ? html`<p class="error">${this._error}</p>` : ''} 301 302 <div class="form"> 303 <grain-form-field .value=${this._title} .maxlength=${100}>
··· 2 import { router } from '../../router.js'; 3 import { auth } from '../../services/auth.js'; 4 import { draftGallery } from '../../services/draft-gallery.js'; 5 import '../atoms/grain-icon.js'; 6 import '../atoms/grain-button.js'; 7 import '../atoms/grain-input.js'; 8 import '../atoms/grain-textarea.js'; 9 import '../molecules/grain-form-field.js'; 10 11 export class GrainCreateGallery extends LitElement { 12 static properties = { 13 _photos: { state: true }, 14 _title: { state: true }, 15 + _description: { state: true } 16 }; 17 18 static styles = css` ··· 84 .form { 85 padding: var(--space-sm); 86 } 87 `; 88 89 constructor() { ··· 91 this._photos = []; 92 this._title = ''; 93 this._description = ''; 94 } 95 96 connectedCallback() { 97 super.connectedCallback(); 98 99 if (!auth.isAuthenticated) { 100 router.replace('/'); 101 return; 102 } 103 104 this._photos = draftGallery.getPhotos(); 105 + 106 + // Restore title/description if returning from descriptions page 107 + this._title = sessionStorage.getItem('draft_title') || ''; 108 + this._description = sessionStorage.getItem('draft_description') || ''; 109 + 110 if (!this._photos.length) { 111 router.push('/'); 112 } ··· 115 #handleBack() { 116 if (confirm('Discard this gallery?')) { 117 draftGallery.clear(); 118 + sessionStorage.removeItem('draft_title'); 119 + sessionStorage.removeItem('draft_description'); 120 history.back(); 121 } 122 } 123 124 #removePhoto(index) { 125 this._photos = this._photos.filter((_, i) => i !== index); 126 + draftGallery.setPhotos(this._photos); 127 if (this._photos.length === 0) { 128 draftGallery.clear(); 129 + sessionStorage.removeItem('draft_title'); 130 + sessionStorage.removeItem('draft_description'); 131 router.push('/'); 132 } 133 } ··· 140 this._description = e.detail.value.slice(0, 1000); 141 } 142 143 + get #canProceed() { 144 + return this._title.trim().length > 0 && this._photos.length > 0; 145 } 146 147 + #handleNext() { 148 + if (!this.#canProceed) return; 149 150 + sessionStorage.setItem('draft_title', this._title); 151 + sessionStorage.setItem('draft_description', this._description); 152 + draftGallery.setPhotos(this._photos); 153 154 + router.push('/create/descriptions'); 155 } 156 157 render() { ··· 164 <span class="header-title">Create a gallery</span> 165 </div> 166 <grain-button 167 + ?disabled=${!this.#canProceed} 168 + @click=${this.#handleNext} 169 + >Next</grain-button> 170 </div> 171 172 <div class="photo-strip"> ··· 177 </div> 178 `)} 179 </div> 180 181 <div class="form"> 182 <grain-form-field .value=${this._title} .maxlength=${100}>
+289
src/components/pages/grain-image-descriptions.js
···
··· 1 + import { LitElement, html, css } from 'lit'; 2 + import { router } from '../../router.js'; 3 + import { auth } from '../../services/auth.js'; 4 + import { draftGallery } from '../../services/draft-gallery.js'; 5 + import { parseTextToFacets } from '../../lib/richtext.js'; 6 + import { grainApi } from '../../services/grain-api.js'; 7 + import '../atoms/grain-icon.js'; 8 + import '../atoms/grain-button.js'; 9 + import '../atoms/grain-textarea.js'; 10 + 11 + const UPLOAD_BLOB_MUTATION = ` 12 + mutation UploadBlob($data: String!, $mimeType: String!) { 13 + uploadBlob(data: $data, mimeType: $mimeType) { 14 + ref 15 + mimeType 16 + size 17 + } 18 + } 19 + `; 20 + 21 + const CREATE_PHOTO_MUTATION = ` 22 + mutation CreatePhoto($input: SocialGrainPhotoInput!) { 23 + createSocialGrainPhoto(input: $input) { 24 + uri 25 + } 26 + } 27 + `; 28 + 29 + const CREATE_GALLERY_MUTATION = ` 30 + mutation CreateGallery($input: SocialGrainGalleryInput!) { 31 + createSocialGrainGallery(input: $input) { 32 + uri 33 + } 34 + } 35 + `; 36 + 37 + const CREATE_GALLERY_ITEM_MUTATION = ` 38 + mutation CreateGalleryItem($input: SocialGrainGalleryItemInput!) { 39 + createSocialGrainGalleryItem(input: $input) { 40 + uri 41 + } 42 + } 43 + `; 44 + 45 + export class GrainImageDescriptions extends LitElement { 46 + static properties = { 47 + _photos: { state: true }, 48 + _title: { state: true }, 49 + _description: { state: true }, 50 + _posting: { state: true }, 51 + _error: { state: true } 52 + }; 53 + 54 + static styles = css` 55 + :host { 56 + display: block; 57 + width: 100%; 58 + max-width: var(--feed-max-width); 59 + min-height: 100%; 60 + background: var(--color-bg-primary); 61 + align-self: center; 62 + } 63 + .header { 64 + display: flex; 65 + align-items: center; 66 + justify-content: space-between; 67 + padding: var(--space-sm); 68 + border-bottom: 1px solid var(--color-border); 69 + } 70 + .header-left { 71 + display: flex; 72 + align-items: center; 73 + gap: var(--space-xs); 74 + } 75 + .back-button { 76 + background: none; 77 + border: none; 78 + padding: 8px; 79 + margin-left: -8px; 80 + cursor: pointer; 81 + color: var(--color-text-primary); 82 + } 83 + .header-title { 84 + font-size: var(--font-size-md); 85 + font-weight: 600; 86 + } 87 + .photo-list { 88 + padding: var(--space-sm); 89 + } 90 + .photo-row { 91 + display: flex; 92 + gap: var(--space-sm); 93 + margin-bottom: var(--space-md); 94 + } 95 + .photo-thumb { 96 + flex-shrink: 0; 97 + max-width: 80px; 98 + max-height: 120px; 99 + width: auto; 100 + height: auto; 101 + border-radius: 4px; 102 + object-fit: contain; 103 + } 104 + .info { 105 + margin: 0; 106 + padding: var(--space-sm); 107 + font-size: var(--font-size-sm); 108 + color: var(--color-text-secondary); 109 + border-bottom: 1px solid var(--color-border); 110 + } 111 + .alt-input { 112 + flex: 1; 113 + } 114 + .alt-input grain-textarea { 115 + --textarea-min-height: 60px; 116 + } 117 + .alt-input grain-textarea::part(textarea) { 118 + min-height: 60px; 119 + } 120 + .error { 121 + color: #ff4444; 122 + padding: var(--space-sm); 123 + text-align: center; 124 + } 125 + `; 126 + 127 + constructor() { 128 + super(); 129 + this._photos = []; 130 + this._title = ''; 131 + this._description = ''; 132 + this._posting = false; 133 + this._error = null; 134 + } 135 + 136 + connectedCallback() { 137 + super.connectedCallback(); 138 + 139 + if (!auth.isAuthenticated) { 140 + router.replace('/'); 141 + return; 142 + } 143 + 144 + this._photos = draftGallery.getPhotos(); 145 + this._title = sessionStorage.getItem('draft_title') || ''; 146 + this._description = sessionStorage.getItem('draft_description') || ''; 147 + 148 + if (!this._photos.length) { 149 + router.push('/'); 150 + } 151 + } 152 + 153 + #handleBack() { 154 + router.push('/create'); 155 + } 156 + 157 + #handleAltChange(index, e) { 158 + const alt = e.detail.value; 159 + draftGallery.updatePhotoAlt(index, alt); 160 + this._photos = [...draftGallery.getPhotos()]; 161 + } 162 + 163 + async #handlePost() { 164 + if (this._posting) return; 165 + 166 + this._posting = true; 167 + this._error = null; 168 + 169 + try { 170 + const client = auth.getClient(); 171 + const now = new Date().toISOString(); 172 + 173 + const photoUris = []; 174 + for (const photo of this._photos) { 175 + const base64Data = photo.dataUrl.split(',')[1]; 176 + const uploadResult = await client.mutate(UPLOAD_BLOB_MUTATION, { 177 + data: base64Data, 178 + mimeType: 'image/jpeg' 179 + }); 180 + 181 + if (!uploadResult.uploadBlob) { 182 + throw new Error('Failed to upload image'); 183 + } 184 + 185 + const photoResult = await client.mutate(CREATE_PHOTO_MUTATION, { 186 + input: { 187 + photo: { 188 + $type: 'blob', 189 + ref: { $link: uploadResult.uploadBlob.ref }, 190 + mimeType: uploadResult.uploadBlob.mimeType, 191 + size: uploadResult.uploadBlob.size 192 + }, 193 + aspectRatio: { 194 + width: photo.width, 195 + height: photo.height 196 + }, 197 + ...(photo.alt && { alt: photo.alt }), 198 + createdAt: now 199 + } 200 + }); 201 + 202 + photoUris.push(photoResult.createSocialGrainPhoto.uri); 203 + } 204 + 205 + let facets = null; 206 + if (this._description.trim()) { 207 + const resolveHandle = async (handle) => grainApi.resolveHandle(handle); 208 + const parsed = await parseTextToFacets(this._description.trim(), resolveHandle); 209 + if (parsed.facets.length > 0) { 210 + facets = parsed.facets; 211 + } 212 + } 213 + 214 + const galleryResult = await client.mutate(CREATE_GALLERY_MUTATION, { 215 + input: { 216 + title: this._title.trim(), 217 + ...(this._description.trim() && { description: this._description.trim() }), 218 + ...(facets && { facets }), 219 + createdAt: now 220 + } 221 + }); 222 + 223 + const galleryUri = galleryResult.createSocialGrainGallery.uri; 224 + 225 + for (let i = 0; i < photoUris.length; i++) { 226 + await client.mutate(CREATE_GALLERY_ITEM_MUTATION, { 227 + input: { 228 + gallery: galleryUri, 229 + item: photoUris[i], 230 + position: i, 231 + createdAt: now 232 + } 233 + }); 234 + } 235 + 236 + draftGallery.clear(); 237 + sessionStorage.removeItem('draft_title'); 238 + sessionStorage.removeItem('draft_description'); 239 + const rkey = galleryUri.split('/').pop(); 240 + router.push(`/profile/${auth.user.handle}/gallery/${rkey}`); 241 + 242 + } catch (err) { 243 + console.error('Failed to create gallery:', err); 244 + this._error = err.message || 'Failed to create gallery. Please try again.'; 245 + } finally { 246 + this._posting = false; 247 + } 248 + } 249 + 250 + render() { 251 + return html` 252 + <div class="header"> 253 + <div class="header-left"> 254 + <button class="back-button" @click=${this.#handleBack}> 255 + <grain-icon name="back" size="20"></grain-icon> 256 + </button> 257 + <span class="header-title">Add image descriptions</span> 258 + </div> 259 + <grain-button 260 + ?loading=${this._posting} 261 + loadingText="Posting..." 262 + @click=${this.#handlePost} 263 + >Post</grain-button> 264 + </div> 265 + 266 + ${this._error ? html`<p class="error">${this._error}</p>` : ''} 267 + 268 + <p class="info">Alt text describes images for blind and low-vision users, and helps give context to everyone.</p> 269 + 270 + <div class="photo-list"> 271 + ${this._photos.map((photo, i) => html` 272 + <div class="photo-row"> 273 + <img class="photo-thumb" src=${photo.dataUrl} alt="Photo ${i + 1}"> 274 + <div class="alt-input"> 275 + <grain-textarea 276 + placeholder="Alt text" 277 + .value=${photo.alt || ''} 278 + .maxlength=${1000} 279 + @input=${(e) => this.#handleAltChange(i, e)} 280 + ></grain-textarea> 281 + </div> 282 + </div> 283 + `)} 284 + </div> 285 + `; 286 + } 287 + } 288 + 289 + customElements.define('grain-image-descriptions', GrainImageDescriptions);
+8 -1
src/services/draft-gallery.js
··· 2 #photos = []; 3 4 setPhotos(photos) { 5 - this.#photos = [...photos]; 6 } 7 8 getPhotos() { 9 return this.#photos; 10 } 11 12 clear() {
··· 2 #photos = []; 3 4 setPhotos(photos) { 5 + // Ensure each photo has an alt property 6 + this.#photos = photos.map(p => ({ ...p, alt: p.alt || '' })); 7 } 8 9 getPhotos() { 10 return this.#photos; 11 + } 12 + 13 + updatePhotoAlt(index, alt) { 14 + if (index >= 0 && index < this.#photos.length) { 15 + this.#photos[index] = { ...this.#photos[index], alt }; 16 + } 17 } 18 19 clear() {