diff --git a/docs/experiments/alt-text-generation.md b/docs/experiments/alt-text-generation.md index f08ec957c..380eae49e 100644 --- a/docs/experiments/alt-text-generation.md +++ b/docs/experiments/alt-text-generation.md @@ -10,7 +10,10 @@ The Alt Text Generation experiment adds an AI-powered "Generate Alt Text" experi When enabled, the Alt Text Generation experiment adds "Generate/Regenerate Alt Text" controls wherever images are edited: -- **Block editor:** In the sidebar when an Image block is selected, an "AI Alternative Text" panel appears with a button to generate or regenerate alt text. After generation, a textarea shows the suggestion with "Apply" and "Dismiss" options. +- **Block editor:** In the sidebar when an Image block is selected, an "AI Alternative Text" panel appears. + - If the image is already marked decorative, Generate Alt Text is not shown and generation is skipped. + - Otherwise, a button generates or regenerates alt text. After generation, a textarea shows the suggestion with "Apply" and "Dismiss". + - If AI determines the image is decorative, the panel suggests marking it decorative instead of applying alt text, with a **Mark as decorative** action that enables the core Image block setting. - **Media modal:** When inserting or editing an image via the media library modal (block editor, classic editor, or site editor), a Generate/Regenerate button appears next to the Alt Text field. Generated text is written into the field and core saves it when the modal is closed. - **Attachment edit screen:** When editing an individual attachment (`Media → Library → Edit`), an "AI Alt Text" meta box or field provides the same Generate/Regenerate button. - **Bulk action (Media Library list view):** In the list view of the Media Library, a "Generate Alt Text" option appears in the Bulk Actions dropdown. Select multiple images, choose the action, and click Apply. A progress notice tracks generation for each image, and query args are automatically stripped from the URL after completion to prevent re-triggering on refresh. @@ -23,14 +26,16 @@ When enabled, the Alt Text Generation experiment adds "Generate/Regenerate Alt T - Supports both attachment IDs (media library images) and image URLs (including external and data URIs) - Output is trimmed and cleaned up (surrounding quotes and trailing periods removed) - Single shared ability (`ai/alt-text-generation`) usable from the UI or directly via REST API +- Skip generation on Image blocks already marked decorative +- When AI detects a decorative image, suggest enabling the core "Mark as decorative" setting instead of applying alt text ### For Developers The experiment consists of three main parts: 1. **Experiment Class** (`WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation`): Handles registration, asset enqueuing, block editor and media UI integration, attachment meta box, media modal field, and bulk action registration/handling -2. **Alt Text Generation Ability** (`WordPress\AI\Abilities\Image\Alt_Text_Generation`): Validates input, resolves image references (attachment ID or URL) to a data URI, calls the AI client with a vision model and system instruction, and returns `{ alt_text: '...' }` -3. **Frontend:** React components for the block editor (`AltTextControls`), plus a DOM-based script (`media.ts`) for the media sidebar and attachment edit form that uses `runAbility` (REST when `wp.abilities.executeAbility` is unavailable) +2. **Alt Text Generation Ability** (`WordPress\AI\Abilities\Image\Alt_Text_Generation`): Validates input, resolves image references (attachment ID or URL) to a data URI, calls the AI client with a vision model and system instruction, and returns `{ alt_text: '...', is_decorative: true|false }` +3. **Frontend:** React components for the block editor (`AltTextGeneration`, `AltTextControls`, `AltTextDisabledNotice`, and `useAltTextFocus`), plus a DOM-based script (`media.ts`) for the media sidebar and attachment edit form that uses `runAbility` (REST when `wp.abilities.executeAbility` is unavailable) The ability can be called directly via REST API for automation, bulk processing, or custom integrations. @@ -47,7 +52,7 @@ The ability can be called directly via REST API for automation, bulk processing, - `bulk_actions-upload` → `register_bulk_action()` adds "Generate Alt Text" to the Media Library list view bulk actions dropdown (gated by `is_enabled()`) - `handle_bulk_actions-upload` → `handle_bulk_action()` filters selected post IDs to image attachments, checks `upload_files` capability, and redirects with `wpai_bulk_alt_text`, `wpai_attachment_ids`, and a `_wpai_bulk_nonce` signature - `attachment_fields_to_edit` → `add_button_to_media_modal()` adds an "AI Alt Text" field with Generate/Regenerate button to the media modal -- `src/experiments/alt-text-generation/index.tsx` uses `addFilter( 'editor.BlockEdit', 'ai/alt-text-generation', ... )` to inject `` into every `core/image` block when the experiment is enabled +- `src/experiments/alt-text-generation/index.tsx` uses `addFilter( 'editor.BlockEdit', 'ai/alt-text-generation', ... )` to inject `` into every `core/image` block when the experiment is enabled - `src/experiments/alt-text-generation/media.ts` finds `.ai-alt-text-media-actions` and the associated textarea (e.g. `#attachment-details-two-column-alt-text`, `#attachment-details-alt-text`, or `#attachment_alt`), wires the Generate button to `runAbility( 'ai/alt-text-generation', { attachment_id } )`, and updates the textarea value and button label on success - Ability implementation: `includes/Abilities/Image/Alt_Text_Generation.php` (extends `Abstract_Ability`) handles input sanitization, permission checks, image reference resolution (attachment or URL → data URI), and calls `wp_ai_client_prompt()->with_file()->generate_text()` using the system instruction at `includes/Abilities/Image/alt-text-system-instruction.php` @@ -62,9 +67,11 @@ The ability can be called directly via REST API for automation, bulk processing, Enqueueing this script *is* the trigger for the generation run, so the nonce check is load-bearing rather than advisory: without it, any authenticated user who loaded an attacker-supplied `upload.php` URL would start a run that overwrites alt text on attacker-chosen attachments. Alt text has no revision history, so that overwrite is unrecoverable. The per-attachment `edit_post` checks in the ability's permission callback and in the REST media controller still bound *what* a run can touch, but they cannot tell a wanted run from an unwanted one. 2. **Block editor (React):** - - The `editor.BlockEdit` filter wraps the Image block with a component that renders `` when the experiment is enabled and the block is `core/image`. + - The `editor.BlockEdit` filter wraps the Image block with a component that renders `` when the experiment is enabled and the block is `core/image`. + - If `attributes.isDecorative` is set, `AltTextGeneration` renders `` and does not call the ability. Otherwise, it renders `` and calls the ability. - `AltTextControls` uses `runAbility( 'ai/alt-text-generation', params )` from `src/utils/run-ability.ts`. Params include `attachment_id` or `image_url` and optionally `context`. The helper uses `wp.abilities.executeAbility` when available, otherwise `apiFetch` to `POST /wp-abilities/v1/abilities/ai/alt-text-generation/run` with `{ input: params }`. - - On success, the component shows a textarea with the generated alt text and Apply/Dismiss buttons; Apply calls `setAttributes( { alt: generatedAlt } )`. + - On success, if `is_decorative` is true, the component shows a notice suggesting the image be marked decorative, with **Mark as decorative** and **Dismiss** actions. **Mark as decorative** sets `isDecorative: true` and clears `alt`, `caption`, `href`, `linkDestination`, `linkTarget`, and `rel` so those values are not left behind, matching core. + - Otherwise the component shows a textarea with the generated alt text and Apply/Dismiss buttons; Apply calls `setAttributes( { alt: generatedAlt } )`. 3. **Media modal & attachment edit (DOM):** - The media script waits for `.ai-alt-text-media-actions` and the corresponding alt textarea (injected by the PHP meta box or `attachment_fields_to_edit`). It attaches a click handler to the Generate button, reads `data-attachment-id`, and calls `runAbility( 'ai/alt-text-generation', { attachment_id } )`. On success it sets the textarea value and dispatches `input`/`change` so core persists the value. @@ -120,6 +127,10 @@ array( 'type' => 'string', 'description' => 'Generated alt text for the image.', ), + 'is_decorative' => array( + 'type' => 'boolean', + 'description' => 'Whether the image was determined to be decorative', + ), ), ) ``` @@ -167,7 +178,8 @@ curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/alt-text ```json { - "alt_text": "A red bicycle leaning against a wooden fence in a sunny park" + "alt_text": "A red bicycle leaning against a wooden fence in a sunny park", + "is_decorative": false } ``` @@ -189,7 +201,8 @@ curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/alt-text ```json { - "alt_text": "Hero image of a team collaborating in a modern office" + "alt_text": "Hero image of a team collaborating in a modern office", + "is_decorative": false } ``` diff --git a/src/experiments/alt-text-generation/components/AltTextControls.tsx b/src/experiments/alt-text-generation/components/AltTextControls.tsx index 4cc074851..0fe82eb53 100644 --- a/src/experiments/alt-text-generation/components/AltTextControls.tsx +++ b/src/experiments/alt-text-generation/components/AltTextControls.tsx @@ -8,11 +8,13 @@ import { Button, TextareaControl, Notice } from '@wordpress/components'; import { update } from '@wordpress/icons'; import { InspectorControls } from '@wordpress/block-editor'; -import { useEffect, useRef, useState } from '@wordpress/element'; +import { type RefCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { dispatch, select } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; import { store as editorStore } from '@wordpress/editor'; +import { Stack } from '@wordpress/ui'; +import { getBlockType } from '@wordpress/blocks'; /** * Internal dependencies @@ -27,6 +29,9 @@ interface AltTextControlsProps { clientId: string; attributes: ImageBlockAttributes; setAttributes: ( attributes: Partial< ImageBlockAttributes > ) => void; + generateButtonRef: RefCallback< HTMLButtonElement | null >; + primaryButtonRef: RefCallback< HTMLButtonElement | null >; + requestFocus: ( target: 'generate' | 'notice' | 'primary' ) => void; } /** @@ -49,68 +54,37 @@ export function getButtonLabel( return __( 'Generate Alt Text', 'ai' ); } -/** - * Decorative notice component. - * - * Displays a notice when an image is decorative. - * - * @return {React.JSX.Element} The component. - */ -export function DecorativeNotice(): React.JSX.Element { - return ( - - { __( - 'This image appears to be decorative. Applying will set an empty alt attribute, which tells screen readers to skip it.', - 'ai' - ) } - - ); -} - /** * AltTextControls component. * * Adds a "Generate Alt Text" button to the image block inspector panel. * - * @param {AltTextControlsProps} props The component props. - * @param {string} props.clientId The block client ID. - * @param {ImageBlockAttributes} props.attributes The block attributes. - * @param {Function} props.setAttributes The function to set the block attributes. + * @param {AltTextControlsProps} props The component props. + * @param {string} props.clientId The block client ID. + * @param {ImageBlockAttributes} props.attributes The block attributes. + * @param {Function} props.setAttributes The function to set the block attributes. + * @param {RefCallback< HTMLButtonElement | null >} props.generateButtonRef The ref to the generate button. + * @param {RefCallback< HTMLButtonElement | null >} props.primaryButtonRef The ref to the primary button. + * @param {Function} props.requestFocus The function to request focus. * @return {React.JSX.Element|null} The component. */ export function AltTextControls( { clientId, attributes, + generateButtonRef, + primaryButtonRef, + requestFocus, setAttributes, }: AltTextControlsProps ): React.JSX.Element | null { const { id: attachmentId, url: imageUrl, alt } = attributes; const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); const [ generatedAlt, setGeneratedAlt ] = useState< string | null >( null ); - const [ isDecorative, setIsDecorative ] = useState< boolean >( false ); + const [ isFoundDecorative, setIsFoundDecorative ] = + useState< boolean >( false ); const hasGeneratedAlt = generatedAlt !== null; - // Refs used to manage keyboard focus as the suggestion UI appears/disappears. - const generateButtonRef = useRef< HTMLButtonElement | null >( null ); - const applyButtonRef = useRef< HTMLButtonElement | null >( null ); - - // Set when Apply/Dismiss is clicked so focus returns to the generate button. - const shouldFocusGenerateRef = useRef< boolean >( false ); - - // Move focus when the suggestion UI appears (after generation) or - // disappears (after Apply/Dismiss). - useEffect( () => { - if ( hasGeneratedAlt || isDecorative ) { - // Generation complete: move focus to the Apply button. - applyButtonRef.current?.focus(); - } else if ( shouldFocusGenerateRef.current ) { - // After Apply/Dismiss: return focus to the Generate/Regenerate button. - shouldFocusGenerateRef.current = false; - generateButtonRef.current?.focus(); - } - }, [ hasGeneratedAlt, isDecorative ] ); - // Don't show controls if there's no image. if ( ! attachmentId && ! imageUrl ) { return null; @@ -128,7 +102,7 @@ export function AltTextControls( { setIsGenerating( true ); setGeneratedAlt( null ); - setIsDecorative( false ); + setIsFoundDecorative( false ); // Clear any previous notices. dispatch( noticesStore ).removeNotice( NOTICE_ID ); @@ -151,8 +125,10 @@ export function AltTextControls( { } ); + requestFocus( 'primary' ); + if ( result.is_decorative ) { - setIsDecorative( true ); + setIsFoundDecorative( true ); setGeneratedAlt( '' ); } else { setGeneratedAlt( result.alt_text ); @@ -174,34 +150,63 @@ export function AltTextControls( { * Applies the generated alt text to the image block. */ const handleApply = () => { - if ( isDecorative ) { - setAttributes( { alt: '' } ); - } else if ( generatedAlt ) { + if ( generatedAlt ) { setAttributes( { alt: generatedAlt } ); } - shouldFocusGenerateRef.current = true; + + requestFocus( 'generate' ); setGeneratedAlt( null ); - setIsDecorative( false ); + setIsFoundDecorative( false ); + }; + + /** + * Enables the core Image block "Mark as decorative" setting. + * + * Mirrors core's implementation: set `isDecorative` and clear + * alt, caption, href, linkDestination, linkTarget, and rel fields so + * those values are not left behind. + */ + const markImageAsDecorative = () => { + // Check whether `isDecorative` is registered before setting it. + const supportsMarkAsDecorative = Object.hasOwn( + getBlockType( 'core/image' )?.attributes ?? {}, + 'isDecorative' + ); + + setAttributes( { + ...( supportsMarkAsDecorative ? { isDecorative: true } : {} ), + alt: '', + caption: undefined, + href: undefined, + linkDestination: undefined, + linkTarget: undefined, + rel: undefined, + } ); + + requestFocus( 'notice' ); + setGeneratedAlt( null ); + setIsFoundDecorative( false ); }; /** * Dismisses the generated alt text suggestion. */ const handleDismiss = () => { - shouldFocusGenerateRef.current = true; + requestFocus( 'generate' ); setGeneratedAlt( null ); - setIsDecorative( false ); + setIsFoundDecorative( false ); }; return ( -
{ /* Generated alt text preview */ } - { hasGeneratedAlt && ! isDecorative && ( -
+ { hasGeneratedAlt && ! isFoundDecorative && ( + setGeneratedAlt( value ) } rows={ 3 } /> -
+ -
-
+ + ) } { /* Decorative image notice */ } - { isDecorative && ( -
- -
+

+ { __( + 'This image appears to be decorative. Consider marking it as decorative so screen readers can skip it.', + 'ai' + ) } +

+ + - -
-
+ { __( 'Mark as decorative', 'ai' ) } + + ) } { /* Generate button */ } - { ! hasGeneratedAlt && ! isDecorative && ( + { ! hasGeneratedAlt && ! isFoundDecorative && (
+
); } diff --git a/src/experiments/alt-text-generation/components/AltTextDisabledNotice.tsx b/src/experiments/alt-text-generation/components/AltTextDisabledNotice.tsx new file mode 100644 index 000000000..b0fa1fe01 --- /dev/null +++ b/src/experiments/alt-text-generation/components/AltTextDisabledNotice.tsx @@ -0,0 +1,58 @@ +/** + * Alt text disabled notice component for the image block inspector. + */ + +/** + * WordPress dependencies + */ +import { InspectorControls } from '@wordpress/block-editor'; +import { Stack } from '@wordpress/ui'; +import { Button, Notice } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import type { RefCallback } from '@wordpress/element'; + +type AltTextDisabledNoticeProps = { + actionButtonRef: RefCallback< HTMLButtonElement | null >; + onEnable: () => void; +}; + +/** + * Renders a notice when the image is marked as decorative and alt text generation is unavailable. + * + * @param {AltTextDisabledNoticeProps} props The component props. + * @param {RefCallback< HTMLButtonElement | null >} props.actionButtonRef The ref to the action button. + * @param {Function} props.onEnable The function to enable alt text generation. + * @return {React.JSX.Element} The rendered component. + */ +export function AltTextDisabledNotice( { + actionButtonRef, + onEnable, +}: AltTextDisabledNoticeProps ): React.JSX.Element { + return ( + + + +

+ { __( + 'This image is marked as decorative. Alt text generation is unavailable while this setting is enabled.', + 'ai' + ) } +

+ + +
+
+
+ ); +} diff --git a/src/experiments/alt-text-generation/components/AltTextGeneration.tsx b/src/experiments/alt-text-generation/components/AltTextGeneration.tsx new file mode 100644 index 000000000..e5ba6d517 --- /dev/null +++ b/src/experiments/alt-text-generation/components/AltTextGeneration.tsx @@ -0,0 +1,65 @@ +/** + * Alt text generation component for the image block inspector. + */ + +/** + * Internal dependencies + */ +import { useAltTextFocus } from '../hooks/useAltTextFocus'; +import type { ImageBlockAttributes } from '../types'; +import { AltTextControls } from './AltTextControls'; +import { AltTextDisabledNotice } from './AltTextDisabledNotice'; + +type AltTextGenerationProps = { + clientId: string; + attributes: ImageBlockAttributes; + setAttributes: ( attributes: Partial< ImageBlockAttributes > ) => void; +}; + +/** + * Renders alt text generation controls for the selected image block. + * + * Shows a notice when the image is marked as decorative and coordinates + * focus as the available controls change. + * + * @param {AltTextGenerationProps} props The component props. + * @param {string} props.clientId The client ID of the selected image block. + * @param {ImageBlockAttributes} props.attributes The attributes of the selected image block. + * @param {Function} props.setAttributes The function to set the attributes of the selected image block. + * @return {React.JSX.Element} The rendered component. + */ +export function AltTextGeneration( { + clientId, + attributes, + setAttributes, +}: AltTextGenerationProps ): React.JSX.Element { + const { + requestFocus, + generateButtonRef, + primaryButtonRef, + noticeButtonRef, + } = useAltTextFocus(); + + if ( attributes?.isDecorative ) { + return ( + { + requestFocus( 'generate' ); + setAttributes( { isDecorative: false } ); + } } + /> + ); + } + + return ( + + ); +} diff --git a/src/experiments/alt-text-generation/components/MediaEditorAltTextControl.tsx b/src/experiments/alt-text-generation/components/MediaEditorAltTextControl.tsx index e1bc7696f..db754fa5f 100644 --- a/src/experiments/alt-text-generation/components/MediaEditorAltTextControl.tsx +++ b/src/experiments/alt-text-generation/components/MediaEditorAltTextControl.tsx @@ -5,7 +5,7 @@ /** * WordPress dependencies */ -import { Button, TextareaControl } from '@wordpress/components'; +import { Button, TextareaControl, Notice } from '@wordpress/components'; import { update } from '@wordpress/icons'; import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -16,7 +16,7 @@ import type { DataFormControlProps } from '@wordpress/dataviews/wp'; /** * Internal dependencies */ -import { getButtonLabel, DecorativeNotice } from './AltTextControls'; +import { getButtonLabel } from './AltTextControls'; import { generateAltText } from '../../../utils/generate-alt-text'; import type { MediaEditorAttachment } from '../types'; @@ -99,7 +99,12 @@ export function MediaEditorAltTextControl( { { /* Decorative image notice. */ } { showDecorativeNotice && (
- + + { __( + 'This image appears to be decorative. Applying will set an empty alt attribute, which tells screen readers to skip it.', + 'ai' + ) } +
) } diff --git a/src/experiments/alt-text-generation/hooks/useAltTextFocus.ts b/src/experiments/alt-text-generation/hooks/useAltTextFocus.ts new file mode 100644 index 000000000..ab32dae0b --- /dev/null +++ b/src/experiments/alt-text-generation/hooks/useAltTextFocus.ts @@ -0,0 +1,104 @@ +/** + * Hook for managing focus between alt text controls as they mount and unmount. + */ + +/** + * WordPress dependencies + */ +import { type RefCallback, useCallback, useRef } from '@wordpress/element'; + +type FocusTarget = 'generate' | 'notice' | 'primary'; + +type UseAltTextFocusReturn = { + requestFocus: ( target: FocusTarget ) => void; + generateButtonRef: RefCallback< HTMLButtonElement | null >; + noticeButtonRef: RefCallback< HTMLButtonElement | null >; + primaryButtonRef: RefCallback< HTMLButtonElement | null >; +}; + +/** + * Manages focus between alt text controls as they mount and unmount. + * + * Focuses the requested button immediately when available, or when it mounts. + * A new request replaces any pending focus request. + * + * @return {UseAltTextFocusReturn} Focus request functions, and button callback refs. + */ +export function useAltTextFocus(): UseAltTextFocusReturn { + // References to the button elements. + const elements = useRef< Record< FocusTarget, HTMLButtonElement | null > >( + { + generate: null, + notice: null, + primary: null, + } + ); + + // The next target to focus on. + const pendingFocus = useRef< FocusTarget | null >( null ); + + /** + * Requests focus on the specified target. + * + * @param {FocusTarget} target The target to focus on. + * @return {void} + */ + const requestFocus = useCallback( ( target: FocusTarget ) => { + const element = elements.current[ target ]; + + // If the element is connected, focus it immediately. + if ( element?.isConnected ) { + pendingFocus.current = null; + element.focus(); + } else { + pendingFocus.current = target; + } + }, [] ); + + /** + * Registers a button element for the specified target. + * + * @param {FocusTarget} target The target to register the button for. + * @param {HTMLButtonElement | null} element The button element to register. + * @return {void} + */ + const register = useCallback( + ( target: FocusTarget, element: HTMLButtonElement | null ) => { + elements.current[ target ] = element; + + // If the element is connected and it's the next target to focus on, + // focus it immediately. + if ( element && pendingFocus.current === target ) { + pendingFocus.current = null; + element.focus(); + } + }, + [] + ); + + // Callback ref that registers the Generate Alt Text button. + const generateButtonRef = useCallback( + ( element: HTMLButtonElement | null ) => + register( 'generate', element ), + [ register ] + ); + + // Callback ref that registers the Decorative Image Notice button. + const noticeButtonRef = useCallback( + ( element: HTMLButtonElement | null ) => register( 'notice', element ), + [ register ] + ); + + // Callback ref that registers the Primary buttons. + const primaryButtonRef = useCallback( + ( element: HTMLButtonElement | null ) => register( 'primary', element ), + [ register ] + ); + + return { + requestFocus, + generateButtonRef, + noticeButtonRef, + primaryButtonRef, + }; +} diff --git a/src/experiments/alt-text-generation/index.tsx b/src/experiments/alt-text-generation/index.tsx index 8241125a5..d68f913c2 100644 --- a/src/experiments/alt-text-generation/index.tsx +++ b/src/experiments/alt-text-generation/index.tsx @@ -11,8 +11,8 @@ import { createHigherOrderComponent } from '@wordpress/compose'; /** * Internal dependencies */ -import { AltTextControls } from './components/AltTextControls'; import type { ImageBlockAttributes } from './types'; +import { AltTextGeneration } from './components/AltTextGeneration'; interface BlockEditProps { clientId: string; @@ -45,7 +45,7 @@ const withAltTextGeneration = createHigherOrderComponent( ( BlockEdit ) => { <> { showControls && ( - { + // Upload a test image so we have a URL the editor can load. + await requestUtils.uploadMedia( TEST_IMAGE_PATH ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Alt Text Generation Experiment', + content: + 'This is some test content for the Alt Text Generation Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Insert a blank image block. + await editor.insertBlock( { + name: 'core/image', + } ); + + // Click the Media Library button in the image block. + const imageBlock = editor.canvas.locator( '.wp-block-image' ).first(); + const mediaLibraryButton = imageBlock + .getByRole( 'button', { name: 'Media Library' } ) + .first(); + await mediaLibraryButton.click(); + + // Click on the first image in the Media Library. + await page.getByRole( 'checkbox' ).first().click(); + + // Click the Select button. + await page.getByRole( 'button', { name: 'Select', exact: true } ).click(); +}; + test.describe( 'Alt Text Generation Experiment', () => { test( 'Can enable the alt text generation experiment', async ( { admin, @@ -206,6 +255,128 @@ test.describe( 'Alt Text Generation Experiment', () => { await editor.saveDraft(); } ); + test( 'Hides Generate Alt Text when the Image block is marked decorative', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Alt Text Generation Experiment. + await enableExperiment( admin, page, 'Alt Text Generation' ); + + await prepareImageBlockInEditor( admin, editor, page, requestUtils ); + + const generateButton = page.getByRole( 'button', { + name: 'Generate Alt Text', + } ); + + // Ensure the alt text generation button is visible + await expect( generateButton ).toBeVisible(); + + // Mark the image block as decorative. + await page + .getByRole( 'checkbox', { name: 'Mark as decorative' } ) + .check(); + + // Ensure the alt text generation button is not visible. + await expect( generateButton ).toBeHidden(); + + // Ensure the Enable alt text generation button is visible. + await expect( + page.getByRole( 'button', { name: 'Enable alt text generation' } ) + ).toBeVisible(); + + // Click the Enable alt text generation button. + await page + .getByRole( 'button', { name: 'Enable alt text generation' } ) + .click(); + + // Ensure the "Mark as decorative" checkbox is now unchecked. + await expect( + page.getByRole( 'checkbox', { name: 'Mark as decorative' } ) + ).not.toBeChecked(); + + // Ensure the alt text generation button is visible. + await expect( generateButton ).toBeVisible(); + } ); + + test( 'Suggests marking the image decorative instead of applying alt text', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Alt Text Generation Experiment. + await enableExperiment( admin, page, 'Alt Text Generation' ); + + await prepareImageBlockInEditor( admin, editor, page, requestUtils ); + + // Mock the AI response to return an empty alt text and a decorative flag. + await page.route( + /\/wp-abilities\/v1\/abilities\/ai\/alt-text-generation\/run/, + async ( route ) => { + await route.fulfill( { + status: 200, + contentType: 'application/json', + body: JSON.stringify( { + alt_text: '', + is_decorative: true, + } ), + } ); + } + ); + + const generateButton = page.getByRole( 'button', { + name: 'Generate Alt Text', + } ); + + // Ensure the alt text generation button is visible + await expect( generateButton ).toBeVisible(); + + // Click the alt text generation button. + await page.getByRole( 'button', { name: 'Generate Alt Text' } ).click(); + + // Ensure the "Mark as decorative" button is visible. + await expect( + page.getByRole( 'button', { name: 'Mark as decorative' } ) + ).toBeVisible(); + + // Ensure the "Generated Alt Text" label and Apply button are not visible. + await expect( page.getByLabel( 'Generated Alt Text' ) ).toBeHidden(); + await expect( + page.getByRole( 'button', { name: 'Apply', exact: true } ) + ).toBeHidden(); + + // Ensure the "Mark as decorative" checkbox is not checked. + await expect( + page.getByRole( 'checkbox', { name: 'Mark as decorative' } ) + ).not.toBeChecked(); + + // Click the "Mark as decorative" button. + await page + .getByRole( 'button', { name: 'Mark as decorative' } ) + .click(); + + // Ensure the alt text generation button is not visible. + await expect( generateButton ).toBeHidden(); + + // Ensure the "Enable alt text generation" button is visible. + await expect( + page.getByRole( 'button', { name: 'Enable alt text generation' } ) + ).toBeVisible(); + + // Ensure the "Mark as decorative" checkbox is now checked. + await expect( + page.getByRole( 'checkbox', { name: 'Mark as decorative' } ) + ).toBeChecked(); + } ); + test( 'Ensure the Alt Text Generation Experiment UI is not visible when Experiments are globally disabled', async ( { admin, editor,