diff --git a/.hermes/plans/2025-08-05_altifier-mvp.md b/.hermes/plans/2025-08-05_altifier-mvp.md new file mode 100644 index 0000000..e788a47 --- /dev/null +++ b/.hermes/plans/2025-08-05_altifier-mvp.md @@ -0,0 +1,353 @@ +# Altifier Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Transform the existing Verifier SPA into Altifier — a tool that scans a user's Bluesky PDS for image posts and replies with missing alt text, generates Section 508-compliant alt text via a free AI vision service, lets the user review/edit/approve, then deletes the old post and recreates it with alt text added. + +**Architecture:** React SPA (forked from Verifier) with Bluesky OAuth login, AT Protocol record enumeration, CDN-backed image display, AI-powered alt text generation (Gemini Flash free tier), and a review/approval workflow. Users explicitly accept that post recreation loses engagement (likes, replies, reposts, quotes). Deployable to Wisp.place/Tangled as a static SPA. + +**Tech Stack:** React 18, @atproto/api, @atproto/oauth-client-browser, React Router, CSS custom properties (Witchsky cyan palette). Alt text: Google Gemini 2.5 Flash (free tier, 1,500 req/day). + +--- + +## Resolved Questions + +| # | Question | Answer | +|---|----------|--------| +| Q1 | **Record strategy** | Delete-and-recreate. `putRecord` is definitively rejected by Bluesky app view ([discussion #3038](https://github.com/bluesky-social/atproto/discussions/3038)). Only Option A works for Bluesky UI visibility. Users accept engagement loss. | +| Q2 | **AI service** | Google Gemini Flash (free tier) — see [AI Options](#ai-service-options) below. | +| Q3 | **Approval workflow** | Review → edit inline → approve per image. Alt text follows [Section 508 guidelines](https://www.section508.gov/create/alternative-text/). | +| Q4 | **Scope** | Interactive tool (login → scan → review → apply). | +| Q5 | **Filter** | Only posts and replies from the authenticated account (not quote-posts, not reposts). | + +--- + +## putRecord Investigation (RESOLVED) + +Bluesky app view **[intentionally ignores](https://github.com/bluesky-social/atproto/discussions/3038#discussioncomment-11308533)** `putRecord` for `app.bsky.feed.post` records. The PDS accepts the write (it's lexicon-agnostic), but the app view serves only the original record. This was confirmed as the answered resolution in the official atproto repo. + +**Implication:** The only way to get alt text into Bluesky's UI is to delete the existing post and create a new one with the updated `embed.images[].alt` fields. This means: +- New post URI (different rkey) +- All engagement resets: likes, reposts, quotes, replies are lost +- The app MUST warn users about this before applying +- Existing replies from others remain but point to the old (now-deleted) post + +--- + +## AI Service Options (User to Choose) + +| Option | Cost | Setup | Rate Limit | +|--------|------|-------|------------| +| **Google Gemini 2.5 Flash** | Free | Get API key at [aistudio.google.com](https://aistudio.google.com) | 1,500 req/day | +| **Ollama + llava/minicpm-v** | Free | Install Ollama locally, pull a vision model | Unlimited (local) | +| **Cloudflare Workers AI** | Free tier | Deploy a Worker, use LLaVA model | 10,000 req/month | + +**Recommended:** Google Gemini 2.5 Flash. Best quality, no local setup, generous free tier. For users who prefer full privacy/offline: Ollama fallback. + +--- + +## Section 508 Alt Text Guidelines (Summary) + +From [section508.gov/create/alternative-text](https://www.section508.gov/create/alternative-text/): + +1. **Short and to the point** — not a visual description, convey the *information* +2. **Communicate what the image conveys**, not how it looks +3. **Don't repeat surrounding text** already in the post +4. **No "Image of..." or "Photo of..."** prefixes +5. **For text in images**: include the text verbatim +6. **Decorative images**: skip alt text entirely (set as decorative) + +--- + +## Phase 1: Foundation + +### Task 1: Rename from Verifier to Altifier + +**Objective:** Update app identity without breaking OAuth or build. + +**Files:** +- Modify: `package.json:2` (name: "altifier") +- Modify: `public/client-metadata.json` (client_name, URIs to altifier domain) +- Modify: `src/contexts/AuthContext.js:6-21` (clientMetadata) +- Modify: `public/index.html` (title, meta) +- Modify: `README.md` (rewrite for altifier) + +**Note:** The current `clientMetadata.client_id` uses `verifier.psingletary.com`. The altifier may deploy to a different domain/subdomain. Keep verifier domain for now and update later during deployment. + +--- + +### Task 2: Add post enumeration utility + +**Objective:** Create `src/utils/postScanner.js` — enumerates all `app.bsky.feed.post` records for the authenticated user, extracting image embeds and alt text status (posts AND replies). + +**Key implementation:** +```js +// src/utils/postScanner.js + +export async function scanImagePosts(agent, did) { + const posts = []; + let cursor; + + do { + const res = await agent.api.com.atproto.repo.listRecords({ + repo: did, + collection: 'app.bsky.feed.post', + limit: 100, + ...(cursor ? { cursor } : {}), + }); + + for (const record of res.data.records || []) { + const embed = record.value?.embed; + if (embed?.$type !== 'app.bsky.embed.images') continue; + + const images = (embed.images || []).map((img, idx) => ({ + index: idx, + blobRef: img.image?.ref?.$link || null, + mimeType: img.image?.mimeType || 'image/jpeg', + currentAlt: img.alt || '', + hasAlt: !!(img.alt && img.alt.trim().length > 0), + cdnUrl: `https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${img.image?.ref?.$link}@jpeg`, + })); + + const missingAlt = images.filter(i => !i.hasAlt); + + posts.push({ + uri: record.uri, + cid: record.cid, + rkey: record.uri.split('/').pop(), + text: record.value.text || '', + createdAt: record.value.createdAt, + isReply: !!(record.value.reply), + images, + missingAltCount: missingAlt.length, + allHaveAlt: missingAlt.length === 0, + }); + } + + cursor = res.data.cursor; + } while (cursor); + + return posts; +} +``` + +**Step 1:** Create `src/utils/postScanner.js` +**Step 2:** Add `scanImagePosts()` and `blobCdnUrl()` helpers +**Step 3:** Verify with a temporary console test + +--- + +### Task 3: Create Altifier page shell with scan trigger + +**Objective:** Add `/altifier` route and page that triggers scanning and shows summary stats. + +**Files:** +- Create: `src/components/Altifier/Altifier.js` +- Modify: Router to add `/altifier` route +- Modify: Navigation to add "Altifier" link + +**Step 1:** Create Altifier.js with useAuth() → Agent, "Scan My Posts" button, loading state +**Step 2:** Summary stats after scan: total image posts, posts missing alt text, images missing alt text +**Step 3:** Filter to show only posts with missing alt text + +--- + +## Phase 2: Core Feature + +### Task 4: Build review/edit/approve UI + +**Objective:** Display posts with missing alt text in a reviewable list. Each image shows: thumbnail, current alt (if any), generated alt suggestion, editable text field, and approve button. Section 508 link opens in new window. + +**Components:** +- `src/components/Altifier/AltifierImageCard.js` — single image: thumbnail + editable alt + approve button +- `src/components/Altifier/AltifierPostCard.js` — groups image cards under post context (text, timestamp, Bluesky link) +- Warning banner: "Recreating posts loses all engagement" + +**Alt text field behavior:** +- Shows AI-generated suggestion pre-filled +- User can edit inline +- "Approve" button per image +- Link to section508.gov opens in new tab +- Character counter (Bluesky alt text max is ~1000 chars but Section 508 says be concise) + +**Step 1:** Create AltifierImageCard — thumbnail, editable textarea, approve button, Section 508 link +**Step 2:** Create AltifierPostCard — post context + image cards +**Step 3:** Integrate into Altifier.js with engagement-loss warning banner +**Step 4:** Verify UI renders with mock data + +--- + +### Task 5: Integrate Gemini Flash for alt text generation + +**Objective:** When images lack alt text, send CDN URL to Gemini 2.5 Flash API and return a Section 508-compliant description. + +**Files:** +- Create: `src/utils/altGenerator.js` + +**Implementation:** + +```js +// src/utils/altGenerator.js + +const GEMINI_API_KEY = process.env.REACT_APP_GEMINI_API_KEY; + +const SYSTEM_PROMPT = `You are an accessibility assistant. Generate alt text for images +following Section 508 guidelines: +1. Short and to the point — convey the information, not the visual appearance +2. Do NOT start with "Image of" or "Photo of" +3. Do NOT repeat any text that appears in the caption/post text +4. If the image contains text, include it verbatim +5. Be specific and concise — describe what the image communicates +6. Max 125 characters +7. Return ONLY the alt text, no other text`; + +export async function generateAltText(imageUrl, postText = '') { + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system_instruction: { parts: [{ text: SYSTEM_PROMPT }] }, + contents: [{ + parts: [ + { text: `Post caption: "${postText || 'No caption'}"` }, + { inline_data: { mime_type: 'image/jpeg', data: await imageUrlToBase64(imageUrl) } } + ] + }] + }) + } + ); + + const data = await response.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; +} + +async function imageUrlToBase64(url) { + const res = await fetch(url); + const blob = await res.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result.split(',')[1]); + reader.readAsDataURL(blob); + }); +} +``` + +**Step 1:** Create `src/utils/altGenerator.js` +**Step 2:** Add API key to `.env` (REACT_APP_GEMINI_API_KEY) — user provides this +**Step 3:** Add "Generate Alt Text" button per post or globally +**Step 4:** Handle errors: rate limits (429), API key missing, network failures + +--- + +### Task 6: Implement post recreation (delete + create with alt text) + +**Objective:** For each approved image, delete the existing post and recreate it with the updated embed containing alt text. + +**Files:** +- Create: `src/utils/altApplier.js` + +**Implementation:** + +```js +// src/utils/altApplier.js + +export async function applyAltText(agent, did, post, approvedAlts) { + // approvedAlts: { [imageIndex]: 'new alt text' } + + // 1. Deep clone the original record value + const newRecord = JSON.parse(JSON.stringify(post.originalValue)); + + // 2. Update alt text for approved images + for (const [idx, altText] of Object.entries(approvedAlts)) { + if (newRecord.embed?.images?.[idx]) { + newRecord.embed.images[idx].alt = altText; + } + } + + // 3. Delete the old post + await agent.api.com.atproto.repo.deleteRecord({ + repo: did, + collection: 'app.bsky.feed.post', + rkey: post.rkey, + }); + + // 4. Create new post with alt text + const result = await agent.api.com.atproto.repo.createRecord({ + repo: did, + collection: 'app.bsky.feed.post', + record: newRecord, + }); + + return result; +} +``` + +**Critical UX:** Before applying, show a confirmation modal: +> "Recreating this post will create a new post with alt text. The original post will be deleted. All likes, reposts, quotes, and replies will be lost. This cannot be undone." + +**Step 1:** Create `src/utils/altApplier.js` +**Step 2:** Add confirmation modal to AltifierPostCard +**Step 3:** Wire "Apply Approved" button +**Step 4:** Handle errors: delete succeeded but create failed (rare — should show error to user, post is now deleted) + +--- + +## Phase 3: Polish & Deploy + +### Task 7: Add filters, progress, and batch operations + +**Objective:** Filter posts (all / missing alt only), show scan progress with pagination, batch approval. + +**Step 1:** Filter toggle: "All image posts" / "Missing alt text only" (default: missing only) +**Step 2:** Show paginated progress during scan ("Scanning posts... 45 of 200") +**Step 3:** "Approve All" button per post (applies to all images in that post) +**Step 4:** "Stop Scan" button for large accounts + +--- + +### Task 8: Update deployment configuration + +**Objective:** Ensure app deploys correctly with new branding. + +**Step 1:** Update `client-metadata.json` with altifier domain +**Step 2:** Verify SPA routing on deployed domain +**Step 3:** Update README with deployment instructions, AI setup guide + +--- + +### Task 9: Documentation and cleanup + +**Objective:** Final README, remove dead Verifier code or keep as reference. + +**Step 1:** Rewrite README with Altifier description, features, AI key setup, known limitations +**Step 2:** Decide: keep Verifier components (unlinked but available) or remove them +**Step 3:** `npm run build` verification +**Step 4:** Commit and push to Tangled + +--- + +## Risks & Tradeoffs + +| Risk | Severity | Mitigation | +|------|----------|------------| +| **Engagement loss** on post recreation | High | Prominent warning, user must explicitly confirm | +| **Delete succeeds, create fails** | High | Error handling that preserves original record data for retry | +| **Gemini free tier exhausted** (1,500/day) | Medium | Show remaining quota, offer Ollama fallback | +| **Large accounts** (thousands of posts) | Medium | Paginated scanning, stop button | +| **OAuth domain mismatch** after rename | Low | Keep verifier metadata until deployment domain is finalized | +| **OneDrive Files On-Demand** breaks npm | Medium | Stage to /tmp for build | + +## Validation + +- [ ] `npm run build` succeeds with zero errors +- [ ] OAuth login flow works +- [ ] Post scanning enumerates all image posts + replies +- [ ] Missing alt text is correctly identified +- [ ] Gemini generates reasonable alt text (Section 508 compliant) +- [ ] User can edit and approve alt text per image +- [ ] Engagement-loss warning is shown and requires confirmation +- [ ] Post recreation: old post deleted, new post created with alt text +- [ ] Section 508 link opens in new tab +- [ ] SPA routing works on deployed host diff --git a/src/App.css b/src/App.css index 53f1d99..879ebf8 100644 --- a/src/App.css +++ b/src/App.css @@ -125,13 +125,6 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } .home-content h1, .login-content h1 { margin-top: 0; } .home-content p, .login-content p { margin-bottom: 20px; } -.home-links { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; -} - .home-button { display: inline-block; padding: 12px 30px; @@ -361,10 +354,11 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } min-height: auto; } +/* Altifier post list */ .altifier-post-list { - list-style: none; - padding: 0; - margin: 0; + display: flex; + flex-direction: column; + gap: 16px; } .altifier-post-card { @@ -372,7 +366,6 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } border: 1px solid var(--card-border); border-radius: 8px; padding: 16px; - margin-bottom: 12px; } .altifier-post-context { @@ -403,46 +396,235 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } font-weight: 600; } -.altifier-post-images { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 10px; -} - -.altifier-image-thumb { +/* Altifier image card — full review card per image */ +.altifier-image-card { border: 2px solid var(--card-border); border-radius: 6px; overflow: hidden; + margin-bottom: 10px; } -.altifier-image-thumb.missing-alt { +.altifier-image-card.missing-alt { border-color: var(--error-text); } -.altifier-image-thumb.has-alt { +.altifier-image-card.has-alt { border-color: var(--success-text); } -.altifier-image-thumb img { +.altifier-image-card-img { width: 100%; - height: 150px; - object-fit: cover; + max-height: 300px; + object-fit: contain; + background: #0a0f14; display: block; } -.altifier-image-label { - padding: 6px 8px; - font-size: 0.8em; - color: var(--text); +.altifier-image-card-body { + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.altifier-image-card-status { + font-size: 0.85em; + color: var(--text-muted); + padding: 6px 10px; background: var(--navbar-bg); + border-radius: 4px; + word-break: break-word; } -.altifier-image-thumb.missing-alt .altifier-image-label { +.altifier-image-card.missing-alt .altifier-image-card-status { background: var(--error-bg); color: var(--error-text); } -.altifier-image-thumb.has-alt .altifier-image-label { - background: var(--success-bg); - color: var(--success-text); +.altifier-image-card-actions { + display: flex; + gap: 8px; +} + +.altifier-generate-button { + background: var(--button-bg); + color: var(--button-text); + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 0.9em; + font-weight: 600; + cursor: pointer; + min-height: 44px; +} + +.altifier-generate-button:hover { background: var(--button-hover-bg); } +.altifier-generate-button:disabled { opacity: 0.6; cursor: not-allowed; } + +.altifier-image-card-error { + font-size: 0.85em; + color: var(--error-text); + padding: 6px 10px; + background: var(--error-bg); + border-radius: 4px; +} + +.altifier-alt-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--card-border); + border-radius: 6px; + background: var(--navbar-bg); + color: var(--text); + font-size: 0.95em; + font-family: inherit; + resize: vertical; + min-height: 60px; + box-sizing: border-box; } + +.altifier-alt-textarea:focus { + border-color: var(--button-bg); + outline: none; +} + +.altifier-char-count { + font-size: 0.8em; + color: var(--text-muted); + text-align: right; +} + +.altifier-image-card-buttons { + display: flex; + gap: 8px; +} + +.altifier-approve-button { + background: var(--success-text); + color: #111822; + border: none; + border-radius: 6px; + padding: 10px 20px; + font-size: 0.95em; + font-weight: 700; + cursor: pointer; + min-height: 48px; + flex: 1; +} + +.altifier-approve-button:hover { filter: brightness(1.1); } +.altifier-approve-button:disabled { opacity: 0.6; cursor: not-allowed; } + +.altifier-reject-button { + background: transparent; + border: 1px solid var(--card-border); + color: var(--text-muted); + border-radius: 6px; + padding: 10px 20px; + font-size: 0.95em; + cursor: pointer; + min-height: 48px; +} + +.altifier-reject-button:hover { border-color: var(--error-text); color: var(--error-text); } +.altifier-reject-button:disabled { opacity: 0.6; cursor: not-allowed; } + +/* Altifier post footer */ +.altifier-post-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--card-border); + flex-wrap: wrap; +} + +.altifier-review-status { + font-size: 0.9em; + color: var(--text-muted); +} + +.altifier-apply-button { + background: var(--button-bg); + color: var(--button-text); + border: none; + border-radius: 6px; + padding: 10px 24px; + font-size: 0.95em; + font-weight: 700; + cursor: pointer; + min-height: 48px; +} + +.altifier-apply-button:hover { background: var(--button-hover-bg); } +.altifier-apply-button:disabled { opacity: 0.6; cursor: not-allowed; } + +/* Confirmation modal */ +.altifier-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 20px; +} + +.altifier-modal { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 24px; + max-width: 440px; + width: 100%; +} + +.altifier-modal h3 { + margin: 0 0 12px 0; + color: var(--text); +} + +.altifier-modal p { + margin: 0 0 8px 0; + color: var(--text-muted); +} + +.altifier-modal-warning { + color: var(--error-text); + font-weight: 600; +} + +.altifier-modal-buttons { + display: flex; + gap: 10px; + margin-top: 16px; +} + +.altifier-modal-confirm { + background: var(--error-text); + color: var(--button-text); + border: none; + border-radius: 6px; + padding: 10px 20px; + font-weight: 700; + cursor: pointer; + min-height: 48px; + flex: 1; +} + +.altifier-modal-confirm:hover { filter: brightness(1.1); } + +.altifier-modal-cancel { + background: transparent; + border: 1px solid var(--card-border); + color: var(--text-muted); + border-radius: 6px; + padding: 10px 20px; + cursor: pointer; + min-height: 48px; +} + +.altifier-modal-cancel:hover { border-color: var(--text-muted); color: var(--text); } diff --git a/src/App.jsx b/src/App.jsx index 9aa8704..71200e8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,7 +5,6 @@ import Footer from './components/Footer/Footer'; import Home from './components/Home/Home'; import Login from './components/Login/Login'; import LoginCallback from './components/Login/LoginCallback'; -import Verifier from './components/Verifier/Verifier'; import Altifier from './components/Altifier/Altifier'; import { AuthProvider } from './contexts/AuthContext'; import ProtectedRoute from './components/ProtectedRoute'; @@ -23,15 +22,6 @@ const App = () => { } /> } /> - - - - } - /> - { ); }; -export default App; +export default App; \ No newline at end of file diff --git a/src/components/Altifier/Altifier.js b/src/components/Altifier/Altifier.js index c796b20..6310bdf 100644 --- a/src/components/Altifier/Altifier.js +++ b/src/components/Altifier/Altifier.js @@ -2,6 +2,8 @@ import React, { useState, useCallback, useEffect } from 'react'; import { useAuth } from '../../contexts/AuthContext'; import { Agent } from '@atproto/api'; import { scanImagePosts } from '../../utils/postScanner'; +import { applyAltText } from '../../utils/altApplier'; +import AltifierPostCard from './AltifierPostCard'; export default function Altifier() { const { session, loading: isAuthLoading, error: authError } = useAuth(); @@ -44,6 +46,24 @@ export default function Altifier() { } }, [agent, session]); + // --- Apply --- + const handleApply = useCallback( + async (post, approvedAlts) => { + if (!agent || !session) throw new Error('Not authenticated'); + const result = await applyAltText(agent, session.did, post, approvedAlts); + + // Remove the post from the list after successful application + setPosts((prev) => prev.filter((p) => p.uri !== post.uri)); + setStatusMessage( + `Post recreated with alt text. New URI: ${result.uri}` + ); + setStatusType('success'); + + return result; + }, + [agent, session] + ); + // --- Derived --- const displayPosts = showMissingOnly ? posts.filter((p) => p.missingAltCount > 0) @@ -128,55 +148,18 @@ export default function Altifier() { : `All Image Posts (${posts.length})`} -
    +
    {displayPosts.map((post) => ( -
  • -
    -

    - {post.text || '(no text)'} -

    -
    - - {new Date(post.createdAt).toLocaleDateString()} - - {post.isReply && ( - Reply - )} - - View on Bluesky - -
    -
    -
    - {post.images.map((img) => ( -
    - {img.cdnUrl && ( - {img.currentAlt - )} -
    - {img.hasAlt - ? `Alt text: ${img.currentAlt.substring(0, 60)}${img.currentAlt.length > 60 ? '...' : ''}` - : 'Missing alt text'} -
    -
    - ))} -
    -
  • + ))} -
+ )} ); -} +} \ No newline at end of file diff --git a/src/components/Altifier/AltifierImageCard.js b/src/components/Altifier/AltifierImageCard.js new file mode 100644 index 0000000..e52dbba --- /dev/null +++ b/src/components/Altifier/AltifierImageCard.js @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; + +export default function AltifierImageCard({ + image, + postText, + onApprove, + onReject, + isApplying, +}) { + const [altText, setAltText] = useState(image.currentAlt || ''); + const [generatedAlt, setGeneratedAlt] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [generateError, setGenerateError] = useState(null); + + const handleGenerate = async () => { + if (!image.cdnUrl) return; + setIsGenerating(true); + setGenerateError(null); + try { + const { generateAltText } = await import('../../utils/altGenerator'); + const result = await generateAltText(image.cdnUrl, postText); + setGeneratedAlt(result); + setAltText(result); + } catch (err) { + setGenerateError(err.message || 'Generation failed'); + } finally { + setIsGenerating(false); + } + }; + + const handleApprove = () => { + if (!altText.trim()) return; + onApprove(image.index, altText.trim()); + }; + + return ( +
+ {image.cdnUrl && ( + {image.currentAlt + )} + +
+
+ {image.hasAlt + ? `Current alt text: "${image.currentAlt.substring(0, 80)}${image.currentAlt.length > 80 ? '...' : ''}"` + : 'Missing alt text'} +
+ +
+ {!image.hasAlt && ( + + )} +
+ + {generateError && ( +
{generateError}
+ )} + +