Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ We don’t charge. We don’t sit in the middle. You use **your** own key. Every
- **In the block editor** — Generate or regenerate alt text right from the Image block.
- **On the media screen** — Button next to the alt field for single images.
- **Logs & export** — See what ran, what cost what, and export as CSV.
- **Fixes older posts too** — Images already in posts pick up their new alt text automatically.
- **Fixes older posts too (optional)** — Turn it on and images already in posts pick up their new alt text automatically.
- **Decorative images** — Purely decorative images (dividers, spacers, patterns) get empty alt text so screen readers skip them, as WCAG recommends.
- **Spending limits** — See estimated spend per month and per model, and set a monthly cap.
- **Your language** — Alt text and titles are written in your site's language (Polylang and WPML supported).
Expand Down Expand Up @@ -150,9 +150,18 @@ The plugin uses the `everyalt` text domain and ships with a `.pot` in `languages
## 📝 Changelog

### Unreleased
- **Accessibility (WCAG 2.1 AA).** A full review of every screen, with all failures fixed:
- Review tabs: alt text and title fields now have labels; Save, Regenerate, and Edit identify which image they act on.
- Results (key validation, queue progress, saves, the Media screen button) are announced to screen readers via WordPress's `wp.a11y.speak`, once per batch rather than once per image.
- Keyboard focus stays on buttons while they work, instead of jumping to the top of the page.
- Success text and the "Key saved" badge now meet 4.5:1 contrast.
- Each tab has its own page title; the tab navigation is a `nav` landmark with the current tab marked.
- The Logs spending bar has an accessible name and value; scrollable log boxes are keyboard-reachable; links that open a new tab say so.
- **Changed** **Existing posts** (filling missing alt text in posts) is now **off by default**, so it's only applied when a site owner turns it on.
- **Improved** the default alt text prompt asks the model to include important visible text (logos, signs, headings).
- **Fixed** estimated costs now include "thinking" tokens that some providers report outside the output count (Gemini's were missing, so Gemini costs were under-reported by about a third). Logs show them as **Thinking Tokens**, and the spending limit uses the corrected amounts.
- **Improved** Gemini now uses Google's Interactions API, which sends images at low resolution (280 tokens instead of 1,120), cutting Gemini's cost per image by roughly half or more. Requests are sent with `store: false`, so Google doesn't keep them for later retrieval.
- **New: alt text shows up in existing posts.** WordPress copies an image's alt text into a post when the image is inserted, so alt text generated later never reached older posts. EveryAlt now fills in empty alt text in post content from the Media Library as pages are displayed (WordPress 6.0+). Posts aren't modified, and alt text written in a post is never replaced. On by default; turn off under **Settings → Existing posts**.
- **New: alt text shows up in existing posts.** WordPress copies an image's alt text into a post when the image is inserted, so alt text generated later never reached older posts. EveryAlt now fills in empty alt text in post content from the Media Library as pages are displayed (WordPress 6.0+). Posts aren't modified, and alt text written in a post is never replaced. Off by default; turn it on under **Settings → Existing posts**.
- **New: background processing.** Bulk generation and new uploads now run in a background queue: close the tab and it keeps going, failures retry automatically (after 1, 5, and 30 minutes), and uploads finish immediately. The queue runs on WP-Cron and also whenever an EveryAlt page is open, so it works on sites where WP-Cron is blocked (e.g. behind HTTP auth). Bulk tabs gain a **Generate for all** button. Prefer the old behavior for uploads? Choose **Settings → New uploads → Generate during the upload**.
- **New: decorative image detection.** When the AI judges an image clearly decorative, its alt text is left empty on purpose (per WCAG) and it's flagged on the Review Alt Text tab with a **Describe anyway** button. When in doubt, the AI describes the image. Turn off under **Settings → Decorative images**.
- **New: spending controls.** The Logs tab shows estimated spend per month and per model. Set a **monthly spending limit** in Settings: when it's reached, generation pauses (queued images wait), admins see a notice, and it resumes next month or when the limit is raised.
Expand Down
52 changes: 48 additions & 4 deletions admin/class-everyalt-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ public function enqueue_scripts() {
wp_enqueue_script(
$this->plugin_name,
plugin_dir_url( __FILE__ ) . 'js/everyalt-admin-simple.js',
array( 'jquery' ),
array( 'jquery', 'wp-a11y' ),
$this->every_alt_asset_version( 'js/everyalt-admin-simple.js' ),
true
);
Expand All @@ -429,6 +429,8 @@ public function enqueue_scripts() {
'queueNothing' => __( 'Those images were already queued.', 'everyalt' ),
'queueCleared' => __( 'Queue cleared.', 'everyalt' ),
'decorative' => __( 'Marked as decorative (alt text left empty on purpose)', 'everyalt' ),
/* translators: %d: number of images finished in this batch */
'queueBatchDone' => __( '%d finished.', 'everyalt' ),
'error' => __( 'Error', 'everyalt' ),
'errorPrefix' => __( 'Error:', 'everyalt' ),
'requestFailed' => __( 'Request failed', 'everyalt' ),
Expand Down Expand Up @@ -508,7 +510,7 @@ public function every_alt_custom_button_to_media_edit_page() {
wp_enqueue_script(
$this->plugin_name . '-media',
plugin_dir_url( __FILE__ ) . 'js/everyalt-media-simple.js',
array( 'jquery' ),
array( 'jquery', 'wp-a11y' ),
$this->every_alt_asset_version( 'js/everyalt-media-simple.js' ),
true
);
Expand Down Expand Up @@ -1065,14 +1067,56 @@ public function add_options_page() {



/**
* The admin page's tabs, slug => label.
*
* @return array
*/
public static function tabs() {
return array(
'settings' => __( 'Settings', 'everyalt' ),
'bulk' => __( 'Bulk Alt Text Generator', 'everyalt' ),
'review' => __( 'Review Alt Text', 'everyalt' ),
'bulk_title' => __( 'Bulk Image Title Generator', 'everyalt' ),
'review_title' => __( 'Review Image Titles', 'everyalt' ),
'logs' => __( 'Logs', 'everyalt' ),
);
}

/**
* Tab being viewed on the EveryAlt page (defaults to settings).
*
* @return string
*/
public static function current_tab() {
$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
return array_key_exists( $tab, self::tabs() ) ? $tab : 'settings';
}

/**
* Filter: admin_title. Put the tab name in the browser title, so each tab has a distinct page
* title (WCAG 2.4.2), e.g. "Logs ‹ EveryAlt ‹ My Site — WordPress".
*
* @param string $admin_title
* @param string $title
* @return string
*/
public function every_alt_admin_title( $admin_title, $title ) {
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
if ( $page !== $this->plugin_name ) {
return $admin_title;
}
$tabs = self::tabs();
return $tabs[ self::current_tab() ] . ' ‹ ' . $admin_title;
}

/**
* Render the options page for plugin
*
* @since 1.0.0
*/
public function display_options_page() {
$allowed_tabs = array( 'settings', 'bulk', 'review', 'bulk_title', 'review_title', 'logs' );
$tab = isset( $_GET['tab'] ) && in_array( $_GET['tab'], $allowed_tabs, true ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings';
$tab = self::current_tab();
$active = $tab;


Expand Down
2 changes: 1 addition & 1 deletion admin/class-everyalt-openai.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

class Every_Alt_OpenAI {

const DEFAULT_PROMPT = 'Describe this image in one short, clear sentence suitable for HTML alt text. Do not start with "This image shows" or similar. Output only the alt text, nothing else.';
const DEFAULT_PROMPT = 'Describe this image in one short, clear sentence suitable for HTML alt text. If the image contains important text (such as a logo, sign, or heading), include that text. Do not start with "This image shows" or similar. Output only the alt text, nothing else.';

const DEFAULT_TITLE_PROMPT = 'Write a short, descriptive title for this image, suitable for a WordPress image title (about 3 to 6 words, Title Case). Do not use quotation marks, a trailing period, or phrases like "This image shows". Output only the title, nothing else.';

Expand Down
2 changes: 1 addition & 1 deletion admin/css/everyalt-admin.css

Large diffs are not rendered by default.

80 changes: 61 additions & 19 deletions admin/js/everyalt-admin-simple.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@
var i18n = (typeof everyaltAdmin !== 'undefined' && everyaltAdmin.i18n) || {};
function t(key, fallback) { return i18n[key] || fallback; }

// Announce a message to screen readers via WordPress's shared live regions (wp.a11y.speak), which
// are always present in the page, so the first message isn't lost like it can be with a live
// region that was just revealed.
function speak(message, assertive) {
if (message && window.wp && wp.a11y && wp.a11y.speak) wp.a11y.speak(message, assertive ? 'assertive' : 'polite');
}

// Mark a button as busy without the disabled attribute: a disabled button loses keyboard focus
// (it jumps to the top of the page), while aria-disabled keeps the user's place. Clicks are
// ignored while busy.
function isBusy(btn) { return btn.getAttribute('aria-disabled') === 'true'; }
function setBusy(btn, busy) {
if (busy) btn.setAttribute('aria-disabled', 'true'); else btn.removeAttribute('aria-disabled');
}

function request(method, path, body) {
var url = restUrl.replace(/\/$/, '') + path;
var opts = {
Expand Down Expand Up @@ -55,14 +70,16 @@
var p = document.createElement('p');
p.textContent = message;
resultEl.appendChild(p);
speak(message, !ok);
}
validateKeyBtn.addEventListener('click', function() {
var keyInput = block ? block.querySelector('input[type="password"]') : null;
var key = keyInput ? keyInput.value.trim() : '';
if (!resultEl) return;
resultEl.style.display = 'none';
resultEl.className = 'everyalt-validate-result';
validateKeyBtn.disabled = true;
if (isBusy(validateKeyBtn)) return;
setBusy(validateKeyBtn, true);
var formData = new FormData();
formData.append('action', 'everyalt_validate_key');
formData.append('nonce', validateKeyNonce);
Expand All @@ -75,12 +92,12 @@
})
.then(function(r) { return r.json(); })
.then(function(data) {
validateKeyBtn.disabled = false;
setBusy(validateKeyBtn, false);
var msg = (data.data && data.data.message) ? data.data.message : (data.success ? '' : t('error', 'Error'));
showResult(!!data.success, msg);
})
.catch(function() {
validateKeyBtn.disabled = false;
setBusy(validateKeyBtn, false);
showResult(false, t('requestFailed', 'Request failed'));
});
});
Expand Down Expand Up @@ -182,8 +199,13 @@
(function step() {
request('POST', '/everyalt-api/v1/queue/process')
.then(function(data) {
(data.results || []).forEach(markItem);
var results = data.results || [];
results.forEach(markItem);
renderQueue(data.status);
// One summary per batch rather than one announcement per image.
if (results.length || (data.status && (data.status.paused || !data.status.total))) {
speak((results.length ? fmt(t('queueBatchDone', '%d finished.'), results.length) + ' ' : '') + queueText.textContent, !!(data.status && data.status.paused));
}
var st = data.status || {};
if (st.paused || !st.total) { queueRunning = false; return; }
if (data.locked || !st.due) {
Expand All @@ -200,22 +222,27 @@
}

function addToQueue(body, btn) {
if (btn) btn.disabled = true;
if (btn) {
if (isBusy(btn)) return Promise.resolve();
setBusy(btn, true);
}
return request('POST', '/everyalt-api/v1/queue', body)
.then(function(data) {
if (btn) btn.disabled = false;
if (btn) setBusy(btn, false);
if (queueLog) {
var li = document.createElement('li');
li.textContent = data.added ? fmt(t('queueAdded', 'Added %d images to the queue.'), data.added) : t('queueNothing', 'Those images were already queued.');
queueLog.insertBefore(li, queueLog.firstChild);
speak(li.textContent);
}
renderQueue(data.status);
runQueue();
})
.catch(function(err) {
if (btn) btn.disabled = false;
if (btn) setBusy(btn, false);
renderQueue({ total: 0 });
queueText.textContent = t('errorPrefix', 'Error:') + ' ' + (err && err.message ? err.message : t('requestFailed', 'Request failed'));
speak(queueText.textContent, true);
});
}

Expand All @@ -239,6 +266,7 @@
request('DELETE', '/everyalt-api/v1/queue').then(function(status) {
renderQueue(status);
queueText.textContent = t('queueCleared', 'Queue cleared.');
speak(queueText.textContent);
});
});
}
Expand All @@ -257,23 +285,26 @@
var textarea = item ? item.querySelector('.everyalt-review-alt-field') : null;
var statusEl = item ? item.querySelector('.everyalt-review-status') : null;
var altText = textarea ? textarea.value : '';
btn.disabled = true;
if (isBusy(btn)) return;
setBusy(btn, true);
request('POST', '/everyalt-api/v1/save_alt', {
media_id: mediaId,
alt_text: altText
})
.then(function() {
btn.disabled = false;
setBusy(btn, false);
if (statusEl) {
statusEl.textContent = t('saved', 'Saved!');
speak(statusEl.textContent);
statusEl.className = 'everyalt-review-status success';
setTimeout(function() { statusEl.textContent = ''; statusEl.className = 'everyalt-review-status'; }, 2000);
}
})
.catch(function(err) {
btn.disabled = false;
setBusy(btn, false);
if (statusEl) {
statusEl.textContent = t('errorPrefix', 'Error:') + ' ' + (err && err.message ? err.message : t('saveFailed', 'Save failed'));
speak(statusEl.textContent, true);
statusEl.className = 'everyalt-review-status error';
}
});
Expand All @@ -287,31 +318,35 @@
var item = btn.closest('.everyalt-review-item');
var textarea = item ? item.querySelector('.everyalt-review-alt-field') : null;
var statusEl = item ? item.querySelector('.everyalt-review-status') : null;
btn.disabled = true;
if (isBusy(btn)) return;
setBusy(btn, true);
if (statusEl) statusEl.textContent = '';
var body = { media_id: mediaId };
if (btn.getAttribute('data-describe')) body.describe = true;
request('POST', '/everyalt-api/v1/bulk_generate_alt', body)
.then(function(data) {
btn.disabled = false;
setBusy(btn, false);
if (data && data.success && data.alt_text !== undefined) {
if (textarea) textarea.value = data.alt_text;
if (statusEl) {
statusEl.textContent = data.decorative ? t('decorative', 'Marked as decorative') : t('regenerated', 'Regenerated!');
speak(statusEl.textContent);
statusEl.className = 'everyalt-review-status success';
setTimeout(function() { statusEl.textContent = ''; statusEl.className = 'everyalt-review-status'; }, 2000);
}
} else {
if (statusEl) {
statusEl.textContent = t('errorPrefix', 'Error:') + ' ' + (data && data.message ? data.message : t('regenerateFailed', 'Regenerate failed'));
speak(statusEl.textContent, true);
statusEl.className = 'everyalt-review-status error';
}
}
})
.catch(function(err) {
btn.disabled = false;
setBusy(btn, false);
if (statusEl) {
statusEl.textContent = t('errorPrefix', 'Error:') + ' ' + (err && err.message ? err.message : t('requestFailed', 'Request failed'));
speak(statusEl.textContent, true);
statusEl.className = 'everyalt-review-status error';
}
});
Expand Down Expand Up @@ -342,23 +377,26 @@
var textarea = item ? item.querySelector('.everyalt-review-title-field') : null;
var statusEl = item ? item.querySelector('.everyalt-review-status') : null;
var title = textarea ? textarea.value : '';
btn.disabled = true;
if (isBusy(btn)) return;
setBusy(btn, true);
request('POST', '/everyalt-api/v1/save_title', {
media_id: mediaId,
title: title
})
.then(function() {
btn.disabled = false;
setBusy(btn, false);
if (statusEl) {
statusEl.textContent = t('saved', 'Saved!');
speak(statusEl.textContent);
statusEl.className = 'everyalt-review-status success';
setTimeout(function() { statusEl.textContent = ''; statusEl.className = 'everyalt-review-status'; }, 2000);
}
})
.catch(function(err) {
btn.disabled = false;
setBusy(btn, false);
if (statusEl) {
statusEl.textContent = t('errorPrefix', 'Error:') + ' ' + (err && err.message ? err.message : t('saveFailed', 'Save failed'));
speak(statusEl.textContent, true);
statusEl.className = 'everyalt-review-status error';
}
});
Expand All @@ -372,29 +410,33 @@
var item = btn.closest('.everyalt-review-title-item');
var textarea = item ? item.querySelector('.everyalt-review-title-field') : null;
var statusEl = item ? item.querySelector('.everyalt-review-status') : null;
btn.disabled = true;
if (isBusy(btn)) return;
setBusy(btn, true);
if (statusEl) statusEl.textContent = '';
request('POST', '/everyalt-api/v1/bulk_generate_title', { media_id: mediaId })
.then(function(data) {
btn.disabled = false;
setBusy(btn, false);
if (data && data.success && data.title !== undefined) {
if (textarea) textarea.value = data.title;
if (statusEl) {
statusEl.textContent = t('regenerated', 'Regenerated!');
speak(statusEl.textContent);
statusEl.className = 'everyalt-review-status success';
setTimeout(function() { statusEl.textContent = ''; statusEl.className = 'everyalt-review-status'; }, 2000);
}
} else {
if (statusEl) {
statusEl.textContent = t('errorPrefix', 'Error:') + ' ' + (data && data.message ? data.message : t('regenerateFailed', 'Regenerate failed'));
speak(statusEl.textContent, true);
statusEl.className = 'everyalt-review-status error';
}
}
})
.catch(function(err) {
btn.disabled = false;
setBusy(btn, false);
if (statusEl) {
statusEl.textContent = t('errorPrefix', 'Error:') + ' ' + (err && err.message ? err.message : t('requestFailed', 'Request failed'));
speak(statusEl.textContent, true);
statusEl.className = 'everyalt-review-status error';
}
});
Expand Down
3 changes: 3 additions & 0 deletions admin/js/everyalt-gutenberg-button.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@
onClick: generateAlt,
isBusy: isBusy,
disabled: isBusy,
// Keep the button focusable while busy (aria-disabled), so keyboard focus isn't lost.
accessibleWhenDisabled: true,
__experimentalIsFocusable: true,
style: { marginTop: '8px' }
}, t('button', 'Generate alt text with EveryAlt'))
: el('p', { className: 'everyalt-gutenberg-help', style: { margin: 0, fontSize: '12px', color: '#757575' } },
Expand Down
Loading
Loading