From afd842b4d48d3da34c6a686146ce471ad5ee8ac4 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Fri, 20 Mar 2026 15:14:01 -0400 Subject: [PATCH 1/8] Create base Content Resizing experiment logic --- .../Content_Resizing/Content_Resizing.php | 177 ++++++++++++++++++ .../Content_Resizing/system-instruction.php | 38 ++++ .../Content_Resizing/Content_Resizing.php | 94 ++++++++++ includes/Experiments/Experiments.php | 1 + 4 files changed, 310 insertions(+) create mode 100644 includes/Abilities/Content_Resizing/Content_Resizing.php create mode 100644 includes/Abilities/Content_Resizing/system-instruction.php create mode 100644 includes/Experiments/Content_Resizing/Content_Resizing.php diff --git a/includes/Abilities/Content_Resizing/Content_Resizing.php b/includes/Abilities/Content_Resizing/Content_Resizing.php new file mode 100644 index 00000000..551b7d2f --- /dev/null +++ b/includes/Abilities/Content_Resizing/Content_Resizing.php @@ -0,0 +1,177 @@ + 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The block content to resize.', 'ai' ), + ), + 'action' => array( + 'type' => 'enum', + 'enum' => array( 'shorten', 'expand', 'rephrase' ), + 'default' => self::ACTION_DEFAULT, + 'description' => esc_html__( 'The resizing action to perform.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'string', + 'description' => esc_html__( 'The resized content.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + // Default arguments. + $args = wp_parse_args( + $input, + array( + 'content' => null, + 'action' => self::ACTION_DEFAULT, + ), + ); + + $content = normalize_content( $args['content'] ?? '' ); + + // If we have no content, return an error. + if ( empty( $content ) ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'Content is required to resize.', 'ai' ) + ); + } + + // Validate minimum word count for the shorten action. + if ( 'shorten' === $args['action'] && str_word_count( wp_strip_all_tags( $content ) ) < self::SHORTEN_MIN_WORDS ) { + return new WP_Error( + 'content_too_short', + esc_html__( 'Text is too short to shorten further.', 'ai' ) + ); + } + + // Generate the resized content. + $result = $this->generate_resized_content( $content, $args['action'] ); + + // If we have an error, return it. + if ( is_wp_error( $result ) ) { + return $result; + } + + // If we have no results, return an error. + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No resized content was generated.', 'ai' ) + ); + } + + // Return the resized content in the format the Ability expects. + return sanitize_text_field( trim( $result ) ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to resize content.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Generates resized content using the AI client. + * + * @since x.x.x + * + * @param string $content The content to resize. + * @param string $action The resizing action to perform. + * @return string|\WP_Error The resized content, or a WP_Error if there was an error. + */ + protected function generate_resized_content( string $content, string $action ) { + $content = '' . $content . ''; + + return wp_ai_client_prompt( $content ) + ->using_system_instruction( $this->get_system_instruction( 'system-instruction.php', array( 'action' => $action ) ) ) + ->using_temperature( 0.7 ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_text(); + } +} diff --git a/includes/Abilities/Content_Resizing/system-instruction.php b/includes/Abilities/Content_Resizing/system-instruction.php new file mode 100644 index 00000000..d3a63f04 --- /dev/null +++ b/includes/Abilities/Content_Resizing/system-instruction.php @@ -0,0 +1,38 @@ + __( 'Content Resizing', 'ai' ), + 'description' => __( 'Shorten, expand, or rephrase selected block content using AI.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers any needed abilities. + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Content_Resizing_Ability::class, + ), + ); + } + + /** + * Enqueues and localizes the admin script. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + // Load asset in new post and edit post screens only. + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + Asset_Loader::enqueue_script( 'content_resizing', 'experiments/content-resizing' ); + Asset_Loader::enqueue_style( 'content_resizing', 'experiments/content-resizing' ); + Asset_Loader::localize_script( + 'content_resizing', + 'ContentResizingData', + array( + 'enabled' => $this->is_enabled(), + ) + ); + } +} diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index 27c8058f..1333a37e 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -28,6 +28,7 @@ final class Experiments { */ private const EXPERIMENT_CLASSES = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, + \WordPress\AI\Experiments\Content_Resizing\Content_Resizing::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, \WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation::class, \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, From d3f25fad5e2be6b01836307ba211e1541702c468 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Fri, 20 Mar 2026 15:26:03 -0400 Subject: [PATCH 2/8] Add Content Resizing UI --- package-lock.json | 7 +- package.json | 1 + .../components/ContentResizingToolbar.tsx | 236 ++++++++++++++++++ src/experiments/content-resizing/index.scss | 27 ++ src/experiments/content-resizing/index.tsx | 45 ++++ src/experiments/content-resizing/types.ts | 6 + webpack.config.js | 5 + 7 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 src/experiments/content-resizing/components/ContentResizingToolbar.tsx create mode 100644 src/experiments/content-resizing/index.scss create mode 100644 src/experiments/content-resizing/index.tsx create mode 100644 src/experiments/content-resizing/types.ts diff --git a/package-lock.json b/package-lock.json index 107030e0..9db22efb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@wordpress/notices": "^5.35.0", "@wordpress/plugins": "^7.34.0", "@wordpress/url": "^4.38.0", + "@wordpress/wordcount": "^4.42.0", "react": "^18.3.1" }, "devDependencies": { @@ -11555,9 +11556,9 @@ } }, "node_modules/@wordpress/wordcount": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@wordpress/wordcount/-/wordcount-4.37.0.tgz", - "integrity": "sha512-Uyl9aR4Tpr/AVoTcqQjkvGE8FE1jXOOooUwcEWCxxe4OLyyKDBs/uJJ2afXzgxc/gNI/QGHArdOA0UArywcnng==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@wordpress/wordcount/-/wordcount-4.42.0.tgz", + "integrity": "sha512-H27okPtQPwgvuLNijYBRjFTbPx9ogSCKvly1/Ps/FFJ8xv1YCL/fPcSFwQ5limXikX0gr4o5DN9PbF22jvUe8A==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", diff --git a/package.json b/package.json index 208f5db5..ea7ecf46 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@wordpress/notices": "^5.35.0", "@wordpress/plugins": "^7.34.0", "@wordpress/url": "^4.38.0", + "@wordpress/wordcount": "^4.42.0", "react": "^18.3.1" }, "overrides": { diff --git a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx new file mode 100644 index 00000000..7d9597a5 --- /dev/null +++ b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx @@ -0,0 +1,236 @@ +/** + * Content resizing toolbar component. + */ + +/** + * WordPress dependencies + */ +import { + Button, + Flex, + Modal, + Spinner, + ToolbarGroup, + ToolbarDropdownMenu, +} from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useState, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { count } from '@wordpress/wordcount'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import type { ContentResizingAction } from '../types'; + +const SHORTEN_MIN_WORDS = 5; + +const AI_ICON = ( + + + + +); + +/** + * Content resizing toolbar component. + * + * @param props Component props. + * @param props.clientId The block client ID. + * @param props.blockName The block name. + */ +export default function ContentResizingToolbar( { + clientId, +}: { + clientId: string; + blockName: string; +} ): JSX.Element { + const [ isLoading, setIsLoading ] = useState( false ); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const [ suggestedContent, setSuggestedContent ] = useState< string | null >( + null + ); + const [ lastAction, setLastAction ] = + useState< ContentResizingAction | null >( null ); + + const blockContent = useSelect( + ( select ) => { + // eslint-disable-next-line dot-notation -- getBlock from store index signature + const block = select( blockEditorStore )[ 'getBlock' ]( clientId ); + return ( block?.attributes?.content as string ) ?? ''; + }, + [ clientId ] + ); + + const blockEditorDispatch = useDispatch( blockEditorStore ) as any; + const noticesDispatch = useDispatch( noticesStore ) as any; + + const handleAction = useCallback( + async ( action: ContentResizingAction ) => { + if ( action === 'shorten' ) { + const wordCount = count( blockContent, 'words', {} ); + // We need at least 5 words to shorten the string. + if ( wordCount < SHORTEN_MIN_WORDS ) { + noticesDispatch.createErrorNotice( + __( 'Text is too short to shorten further.', 'ai' ), + { + id: 'ai_content_resizing_error', + isDismissible: true, + } + ); + return; + } + } + + setIsLoading( true ); + setLastAction( action ); + setSuggestedContent( null ); + setIsModalOpen( true ); + + // Remove any previous error notices. + noticesDispatch.removeNotice( 'ai_content_resizing_error' ); + + try { + const result = await runAbility< string >( + 'ai/content-resizing', + { content: blockContent, action } + ); + setSuggestedContent( result ); + } catch ( error: unknown ) { + const message = + error instanceof Error + ? error.message + : __( + 'An error occurred while resizing content.', + 'ai' + ); + + noticesDispatch.createErrorNotice( message, { + id: 'ai_content_resizing_error', + isDismissible: true, + } ); + setIsModalOpen( false ); + } finally { + setIsLoading( false ); + } + }, + [ blockContent, noticesDispatch ] + ); + + /** + * Handles accepting the suggested content. + */ + const handleAccept = useCallback( () => { + if ( suggestedContent !== null ) { + blockEditorDispatch.updateBlockAttributes( clientId, { + content: suggestedContent, + } ); + } + setSuggestedContent( null ); + setLastAction( null ); + setIsModalOpen( false ); + }, [ blockEditorDispatch, clientId, suggestedContent ] ); + + /** + * Handles closing the modal. + */ + const closeModal = useCallback( () => { + setSuggestedContent( null ); + setLastAction( null ); + setIsModalOpen( false ); + }, [] ); + + /** + * Handles retrying the action. + */ + const handleRetry = useCallback( () => { + if ( lastAction ) { + handleAction( lastAction ); + } + }, [ handleAction, lastAction ] ); + + return ( + <> + + handleAction( 'shorten' ), + }, + { + title: __( 'Expand', 'ai' ), + onClick: () => handleAction( 'expand' ), + }, + { + title: __( 'Rephrase', 'ai' ), + onClick: () => handleAction( 'rephrase' ), + }, + ] } + /> + + { isModalOpen && ( + + { isLoading ? ( +
+ +

{ __( 'Generating…', 'ai' ) }

+
+ ) : ( + <> +
+ + + + + + + ) } + + ) } + + ); +} diff --git a/src/experiments/content-resizing/index.scss b/src/experiments/content-resizing/index.scss new file mode 100644 index 00000000..25c15040 --- /dev/null +++ b/src/experiments/content-resizing/index.scss @@ -0,0 +1,27 @@ +.ai-content-resizing-modal__loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 32px 0; + + p { + color: #757575; + margin: 0; + } +} + +.ai-content-resizing-modal__text { + background: rgba(147, 51, 234, 0.06); + border-left: 3px solid rgba(147, 51, 234, 0.4); + border-radius: 2px; + padding: 12px; + font-size: 14px; + line-height: 1.6; + max-height: 300px; + overflow-y: auto; +} + +.ai-content-resizing-modal__actions { + margin-top: 16px; +} diff --git a/src/experiments/content-resizing/index.tsx b/src/experiments/content-resizing/index.tsx new file mode 100644 index 00000000..1c138916 --- /dev/null +++ b/src/experiments/content-resizing/index.tsx @@ -0,0 +1,45 @@ +/** + * Content resizing plugin registration. + */ + +/** + * WordPress dependencies + */ +import { BlockControls } from '@wordpress/block-editor'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import ContentResizingToolbar from './components/ContentResizingToolbar'; +import './index.scss'; + +const { aiContentResizingData } = window as any; + +const withContentResizing = createHigherOrderComponent( ( BlockEdit ) => { + return ( props: any ) => { + if ( + props.name !== 'core/paragraph' || + ! aiContentResizingData?.enabled + ) { + return ; + } + + return ( + <> + { props.isSelected && ( + + + + ) } + + + ); + }; +}, 'withContentResizing' ); + +addFilter( 'editor.BlockEdit', 'ai/content-resizing', withContentResizing ); diff --git a/src/experiments/content-resizing/types.ts b/src/experiments/content-resizing/types.ts new file mode 100644 index 00000000..ae6f68d7 --- /dev/null +++ b/src/experiments/content-resizing/types.ts @@ -0,0 +1,6 @@ +export type ContentResizingAction = 'shorten' | 'expand' | 'rephrase'; + +export interface ContentResizingAbilityInput { + content: string; + action: ContentResizingAction; +} diff --git a/webpack.config.js b/webpack.config.js index bb929257..6329029b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -24,6 +24,11 @@ module.exports = { 'src/experiments/abilities-explorer', 'index.js' ), + 'experiments/content-resizing': path.resolve( + process.cwd(), + 'src/experiments/content-resizing', + 'index.tsx' + ), 'experiments/example-experiment': path.resolve( process.cwd(), 'src/experiments/example-experiment', From b45b4a57be9695aaea9e5574ab2627cf62167401 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Mon, 23 Mar 2026 08:57:51 -0400 Subject: [PATCH 3/8] Apply filters to prompt and temperature. Update system-instruction to not require PHP processing. --- .../Content_Resizing/Content_Resizing.php | 86 ++++++++++++++++--- .../Content_Resizing/system-instruction.php | 23 ++--- .../components/ContentResizingToolbar.tsx | 2 +- 3 files changed, 81 insertions(+), 30 deletions(-) diff --git a/includes/Abilities/Content_Resizing/Content_Resizing.php b/includes/Abilities/Content_Resizing/Content_Resizing.php index 551b7d2f..f72c3f3b 100644 --- a/includes/Abilities/Content_Resizing/Content_Resizing.php +++ b/includes/Abilities/Content_Resizing/Content_Resizing.php @@ -38,7 +38,7 @@ class Content_Resizing extends Abstract_Ability { * * @var int */ - protected const SHORTEN_MIN_WORDS = 10; + protected const SHORTEN_MIN_WORDS = 5; /** * {@inheritDoc} @@ -105,12 +105,29 @@ protected function execute_callback( $input ) { if ( 'shorten' === $args['action'] && str_word_count( wp_strip_all_tags( $content ) ) < self::SHORTEN_MIN_WORDS ) { return new WP_Error( 'content_too_short', - esc_html__( 'Text is too short to shorten further.', 'ai' ) + sprintf( + /* translators: %d: Minimum word count. */ + esc_html__( 'A minimum of %d words is required to shorten the content.', 'ai' ), + self::SHORTEN_MIN_WORDS + ) ); } + $prompt = $this->build_prompt( $content, $args['action'] ); + + /** + * Filters the prompt for the content resizing. + * + * @since x.x.x + * + * @param string $prompt The prompt to use for the content resizing. + * @param string $action The resizing action to perform. + * @return string The filtered prompt. + */ + $prompt = (string) apply_filters( 'wpai_content_resizing_prompt', $prompt, $args['action'] ); + // Generate the resized content. - $result = $this->generate_resized_content( $content, $args['action'] ); + $result = $this->generate_resized_content( $prompt, $args['action'] ); // If we have an error, return it. if ( is_wp_error( $result ) ) { @@ -126,7 +143,7 @@ protected function execute_callback( $input ) { } // Return the resized content in the format the Ability expects. - return sanitize_text_field( trim( $result ) ); + return wp_kses_post( $result ); } /** @@ -157,20 +174,67 @@ protected function meta(): array { } /** - * Generates resized content using the AI client. + * Builds the the prompt for content resizing. * * @since x.x.x * * @param string $content The content to resize. * @param string $action The resizing action to perform. - * @return string|\WP_Error The resized content, or a WP_Error if there was an error. + * @return string The prompt. */ - protected function generate_resized_content( string $content, string $action ) { - $content = '' . $content . ''; + protected function build_prompt( $content, $action = self::ACTION_DEFAULT ) { + $prompt_parts = array(); + + // Determine the action-specific instruction. + $action_desc = 'Rephrase the content using different wording and sentence structure while preserving the exact same meaning, tone, and level of detail. The output should be approximately the same length as the input.'; + if ( 'shorten' === $action ) { + $action_desc = 'Condense the following text to roughly half its current length. Preserve the core meaning, key facts, and tone. Remove redundancy and filler. Do not add new information.'; + } elseif ( 'expand' === $action ) { + $action_desc = 'Expand the following text to roughly 1.5 to 2 times its current length. Add supporting detail, elaboration, or examples that are consistent with the original meaning and tone. Do not introduce contradictory information.'; + } + + /** + * Filters the action description for the content resizing. + * + * @since x.x.x + * + * @param string $action_desc The action description to use for the content resizing. + * @param string $action The resizing action to perform. + * @return string The filtered action description. + */ + $action_desc = (string) apply_filters( 'wpai_content_resizing_action_description', $action_desc, $action ); + + $prompt_parts[] = '' . $action_desc . ''; + $prompt_parts[] = '' . $content . ''; + + return implode( "\n", $prompt_parts ); + } - return wp_ai_client_prompt( $content ) - ->using_system_instruction( $this->get_system_instruction( 'system-instruction.php', array( 'action' => $action ) ) ) - ->using_temperature( 0.7 ) + /** + * Generates resized content using the AI client. + * + * @since x.x.x + * + * @param string $prompt The prompt to use for the content resizing. + * @param string $action The resizing action to perform. + * @return string|\WP_Error The resized content, or a WP_Error if there was an error. + */ + protected function generate_resized_content( string $prompt, string $action ) { + /** + * Filters the temperature for the content resizing. + * Default is 0.7. + * + * @since x.x.x + * + * @param float $temperature The temperature to use for the content resizing. + * @param string $action The resizing action to perform. + * @return float The filtered temperature. + */ + $temperature = (float) apply_filters( 'wpai_content_resizing_temperature', 0.7, $action ); + + return wp_ai_client_prompt( $prompt ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_temperature( $temperature ) ->using_model_preference( ...get_preferred_models_for_text_generation() ) ->generate_text(); } diff --git a/includes/Abilities/Content_Resizing/system-instruction.php b/includes/Abilities/Content_Resizing/system-instruction.php index d3a63f04..49bec8be 100644 --- a/includes/Abilities/Content_Resizing/system-instruction.php +++ b/includes/Abilities/Content_Resizing/system-instruction.php @@ -10,29 +10,16 @@ exit; } -// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound - -// Determine the action-specific instruction. -$action_desc = 'Rephrase the following text using different wording and sentence structure while preserving the exact same meaning, tone, and level of detail. The output should be approximately the same length as the input.'; -if ( isset( $action ) ) { - if ( 'shorten' === $action ) { - $action_desc = 'Condense the following text to roughly half its current length. Preserve the core meaning, key facts, and tone. Remove redundancy and filler. Do not add new information.'; - } elseif ( 'expand' === $action ) { - $action_desc = 'Expand the following text to roughly 1.5 to 2 times its current length. Add supporting detail, elaboration, or examples that are consistent with the original meaning and tone. Do not introduce contradictory information.'; - } -} - // phpcs:ignore Squiz.PHP.Heredoc.NotAllowed, PluginCheck.CodeAnalysis.Heredoc.NotAllowed -return <<` tags) while preserving meaning and intent. The content may contain inline HTML tags (such as strong, em, a, code). Requirements: +- Follow the primary goal defined in the `` tag - Return only the transformed text, nothing else - Do not include any preamble, explanation, or commentary -- Do not include any markdown formatting, bullets, or numbering unless they were present in the original -- Preserve any inline HTML tags (such as strong, em, a, code) that are present in the original content +- Preserve all inline HTML links present in the original content. +- Return content in the same format as it was provided. - Match the original language of the content - Maintain the original perspective and voice INSTRUCTION; diff --git a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx index 7d9597a5..0a4b4df4 100644 --- a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx +++ b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx @@ -80,7 +80,7 @@ export default function ContentResizingToolbar( { async ( action: ContentResizingAction ) => { if ( action === 'shorten' ) { const wordCount = count( blockContent, 'words', {} ); - // We need at least 5 words to shorten the string. + // We need at least 5 words to shorten the content. if ( wordCount < SHORTEN_MIN_WORDS ) { noticesDispatch.createErrorNotice( __( 'Text is too short to shorten further.', 'ai' ), From 2bdfd0b8ef9c6d55b7f6cbb57cb6ad9250c18f78 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Mon, 23 Mar 2026 09:07:10 -0400 Subject: [PATCH 4/8] Create icons for the Content Rephrasing actions --- .../components/ContentResizingToolbar.tsx | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx index 0a4b4df4..037096b0 100644 --- a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx +++ b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx @@ -28,7 +28,10 @@ import type { ContentResizingAction } from '../types'; const SHORTEN_MIN_WORDS = 5; -const AI_ICON = ( +/** + * AI icon: Sparkling stars. + */ +const ICON_AI = ( ); +/** + * Shorten icon: two horizontal arrows pointing inward. + */ +const ICON_SHORTEN = ( + + + +); + +/** + * Expand icon: two horizontal arrows pointing outward. + */ +const ICON_EXPAND = ( + + + +); + +/** + * Rephrase icon: curved arrow forming a circle. + */ +const ICON_REPHRASE = ( + + + +); + /** * Content resizing toolbar component. * @@ -164,19 +212,22 @@ export default function ContentResizingToolbar( { <> handleAction( 'shorten' ), }, { title: __( 'Expand', 'ai' ), + icon: ICON_EXPAND, onClick: () => handleAction( 'expand' ), }, { title: __( 'Rephrase', 'ai' ), + icon: ICON_REPHRASE, onClick: () => handleAction( 'rephrase' ), }, ] } From a7bb440dcebe2e5117fe02d7ab4b0f67b5249e72 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Mon, 23 Mar 2026 09:20:33 -0400 Subject: [PATCH 5/8] Apply an undo action to the AI generated content --- .../components/ContentResizingToolbar.tsx | 120 +++++++++++++----- src/experiments/content-resizing/index.scss | 6 + src/experiments/content-resizing/index.tsx | 22 ++++ 3 files changed, 117 insertions(+), 31 deletions(-) diff --git a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx index 037096b0..b9fce305 100644 --- a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx +++ b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx @@ -91,6 +91,21 @@ const ICON_REPHRASE = ( ); +/** + * Undo icon: arrow curving back to the left. + */ +const ICON_UNDO = ( + + + +); + /** * Content resizing toolbar component. * @@ -112,15 +127,21 @@ export default function ContentResizingToolbar( { const [ lastAction, setLastAction ] = useState< ContentResizingAction | null >( null ); - const blockContent = useSelect( + const { blockContent, originalContent } = useSelect( ( select ) => { // eslint-disable-next-line dot-notation -- getBlock from store index signature const block = select( blockEditorStore )[ 'getBlock' ]( clientId ); - return ( block?.attributes?.content as string ) ?? ''; + return { + blockContent: ( block?.attributes?.content as string ) ?? '', + originalContent: + ( block?.attributes?.aiOriginalContent as string ) ?? '', + }; }, [ clientId ] ); + const hasOriginalContent = originalContent.length > 0; + const blockEditorDispatch = useDispatch( blockEditorStore ) as any; const noticesDispatch = useDispatch( noticesStore ) as any; @@ -163,7 +184,6 @@ export default function ContentResizingToolbar( { 'An error occurred while resizing content.', 'ai' ); - noticesDispatch.createErrorNotice( message, { id: 'ai_content_resizing_error', isDismissible: true, @@ -176,61 +196,99 @@ export default function ContentResizingToolbar( { [ blockContent, noticesDispatch ] ); - /** - * Handles accepting the suggested content. - */ const handleAccept = useCallback( () => { if ( suggestedContent !== null ) { + // Save the current content as original before replacing, + // but only if we don't already have an original saved. + const original = hasOriginalContent + ? originalContent + : blockContent; + blockEditorDispatch.updateBlockAttributes( clientId, { content: suggestedContent, + aiOriginalContent: original, } ); } setSuggestedContent( null ); setLastAction( null ); setIsModalOpen( false ); - }, [ blockEditorDispatch, clientId, suggestedContent ] ); + }, [ + blockContent, + blockEditorDispatch, + clientId, + hasOriginalContent, + originalContent, + suggestedContent, + ] ); + + const handleUndo = useCallback( () => { + if ( hasOriginalContent ) { + blockEditorDispatch.updateBlockAttributes( clientId, { + content: originalContent, + aiOriginalContent: '', + } ); + } + }, [ blockEditorDispatch, clientId, hasOriginalContent, originalContent ] ); - /** - * Handles closing the modal. - */ const closeModal = useCallback( () => { setSuggestedContent( null ); setLastAction( null ); setIsModalOpen( false ); }, [] ); - /** - * Handles retrying the action. - */ const handleRetry = useCallback( () => { if ( lastAction ) { handleAction( lastAction ); } }, [ handleAction, lastAction ] ); + const controls: Array< { + title: string; + icon: JSX.Element; + onClick: () => void; + } > = []; + + // If we have original content, + // add the undo control at the beginning of the dropdown. + if ( hasOriginalContent ) { + controls.push( { + title: __( 'Undo AI changes', 'ai' ) as string, + icon: ICON_UNDO, + onClick: handleUndo, + } ); + } + + controls.push( + { + title: __( 'Shorten', 'ai' ) as string, + icon: ICON_SHORTEN, + onClick: () => handleAction( 'shorten' ), + }, + { + title: __( 'Expand', 'ai' ) as string, + icon: ICON_EXPAND, + onClick: () => handleAction( 'expand' ), + }, + { + title: __( 'Rephrase', 'ai' ) as string, + icon: ICON_REPHRASE, + onClick: () => handleAction( 'rephrase' ), + } + ); + return ( <> - + handleAction( 'shorten' ), - }, - { - title: __( 'Expand', 'ai' ), - icon: ICON_EXPAND, - onClick: () => handleAction( 'expand' ), - }, - { - title: __( 'Rephrase', 'ai' ), - icon: ICON_REPHRASE, - onClick: () => handleAction( 'rephrase' ), - }, - ] } + controls={ controls } /> { isModalOpen && ( diff --git a/src/experiments/content-resizing/index.scss b/src/experiments/content-resizing/index.scss index 25c15040..9f7aeddb 100644 --- a/src/experiments/content-resizing/index.scss +++ b/src/experiments/content-resizing/index.scss @@ -1,3 +1,9 @@ +.ai-content-resizing-toolbar--has-changes { + .components-button { + color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9)); + } +} + .ai-content-resizing-modal__loading { display: flex; flex-direction: column; diff --git a/src/experiments/content-resizing/index.tsx b/src/experiments/content-resizing/index.tsx index 1c138916..ab5907c0 100644 --- a/src/experiments/content-resizing/index.tsx +++ b/src/experiments/content-resizing/index.tsx @@ -17,6 +17,28 @@ import './index.scss'; const { aiContentResizingData } = window as any; +// Register the aiOriginalContent attribute on paragraph blocks. +addFilter( + 'blocks.registerBlockType', + 'ai/content-resizing-attribute', + ( settings, name ) => { + if ( name !== 'core/paragraph' ) { + return settings; + } + + return { + ...settings, + attributes: { + ...settings.attributes, + aiOriginalContent: { + type: 'string', + default: '', + }, + }, + }; + } +); + const withContentResizing = createHigherOrderComponent( ( BlockEdit ) => { return ( props: any ) => { if ( From b2cd0590a97d282c7dcab91b7c3352d7b68f4b05 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Mon, 23 Mar 2026 09:26:14 -0400 Subject: [PATCH 6/8] Change label of dropdown button when AI content is applied --- .../content-resizing/components/ContentResizingToolbar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx index b9fce305..803a4b51 100644 --- a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx +++ b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx @@ -248,6 +248,10 @@ export default function ContentResizingToolbar( { onClick: () => void; } > = []; + const dropdownLabel = hasOriginalContent + ? __( 'Has AI Content', 'ai' ) + : __( 'AI Content Resize', 'ai' ); + // If we have original content, // add the undo control at the beginning of the dropdown. if ( hasOriginalContent ) { @@ -287,7 +291,7 @@ export default function ContentResizingToolbar( { > From 78f17b183a266e3a9258b05cd07be3eb0b38a2ef Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Mon, 23 Mar 2026 09:37:21 -0400 Subject: [PATCH 7/8] Create unit tests --- src/experiments/content-resizing/index.scss | 2 +- .../Abilities/Content_ResizingTest.php | 431 ++++++++++++++++++ .../Content_Resizing/Content_ResizingTest.php | 104 +++++ 3 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Includes/Abilities/Content_ResizingTest.php create mode 100644 tests/Integration/Includes/Experiments/Content_Resizing/Content_ResizingTest.php diff --git a/src/experiments/content-resizing/index.scss b/src/experiments/content-resizing/index.scss index 9f7aeddb..3f35b9c8 100644 --- a/src/experiments/content-resizing/index.scss +++ b/src/experiments/content-resizing/index.scss @@ -12,7 +12,7 @@ padding: 32px 0; p { - color: #757575; + color: var(--wp-components-color-gray-700, #757575); margin: 0; } } diff --git a/tests/Integration/Includes/Abilities/Content_ResizingTest.php b/tests/Integration/Includes/Abilities/Content_ResizingTest.php new file mode 100644 index 00000000..355d16dd --- /dev/null +++ b/tests/Integration/Includes/Abilities/Content_ResizingTest.php @@ -0,0 +1,431 @@ + 'Content Resizing', + 'description' => 'Shorten, expand, or rephrase selected block content using AI.', + ); + } + + /** + * Registers the experiment. + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Content Resizing Ability test case. + * + * @since x.x.x + */ +class Content_ResizingTest extends WP_UnitTestCase { + + /** + * Content Resizing ability instance. + * + * @var \WordPress\AI\Abilities\Content_Resizing\Content_Resizing + */ + private $ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Content_Resizing_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Content_Resizing_Experiment(); + $this->ability = new Content_Resizing( + 'ai/content-resizing', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since x.x.x + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); + $this->assertArrayHasKey( 'action', $schema['properties'], 'Schema should have action property' ); + + // Verify content property. + $this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['content']['sanitize_callback'], 'Content should use sanitize_text_field' ); + + // Verify action property. + $this->assertEquals( 'enum', $schema['properties']['action']['type'], 'Action should be enum type' ); + $this->assertEquals( array( 'shorten', 'expand', 'rephrase' ), $schema['properties']['action']['enum'], 'Action should have shorten, expand, rephrase values' ); + $this->assertEquals( 'rephrase', $schema['properties']['action']['default'], 'Action default should be rephrase' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'string', $schema['type'], 'Schema type should be string' ); + $this->assertArrayHasKey( 'description', $schema, 'Schema should have description' ); + } + + /** + * Test that get_system_instruction() returns the system instruction. + * + * @since x.x.x + */ + public function test_get_system_instruction_returns_system_instruction() { + $system_instruction = $this->ability->get_system_instruction(); + + $this->assertIsString( $system_instruction, 'System instruction should be a string' ); + $this->assertNotEmpty( $system_instruction, 'System instruction should not be empty' ); + } + + /** + * Test that execute_callback() returns error when content is missing. + * + * @since x.x.x + */ + public function test_execute_callback_without_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() returns error when content is empty. + * + * @since x.x.x + */ + public function test_execute_callback_with_empty_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'content' => '' ) ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() returns error when shorten action is used with short content. + * + * @since x.x.x + */ + public function test_execute_callback_shorten_with_short_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( + $this->ability, + array( + 'content' => 'Too few words.', + 'action' => 'shorten', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_too_short', $result->get_error_code(), 'Error code should be content_too_short' ); + } + + /** + * Test that execute_callback() does not return content_too_short for expand action with short content. + * + * @since x.x.x + */ + public function test_execute_callback_expand_with_short_content_does_not_error() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + try { + $result = $method->invoke( + $this->ability, + array( + 'content' => 'Short text.', + 'action' => 'expand', + ) + ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + // If it fails, it should not be content_too_short. + $this->assertNotEquals( 'content_too_short', $result->get_error_code(), 'Expand action should not trigger content_too_short error' ); + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + } + + /** + * Test that execute_callback() with valid content calls the AI client. + * + * @since x.x.x + */ + public function test_execute_callback_with_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This is some test content that needs to be resized. It contains multiple sentences to provide enough context for a meaningful transformation.', + 'action' => 'rephrase', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that execute_callback() defaults to rephrase action. + * + * @since x.x.x + */ + public function test_execute_callback_defaults_to_rephrase_action() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This is some test content that needs to be resized. It contains multiple sentences to provide enough context for a meaningful transformation.', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_with_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); + } + + /** + * Test that permission_callback() returns error for user without edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_without_edit_posts_capability() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns error for logged out user. + * + * @since x.x.x + */ + public function test_permission_callback_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that meta() returns the expected meta structure. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + } + + /** + * Test that build_prompt() includes goal and content tags. + * + * @since x.x.x + */ + public function test_build_prompt_includes_goal_and_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'build_prompt' ); + $method->setAccessible( true ); + + $prompt = $method->invoke( $this->ability, 'Test content here.', 'shorten' ); + + $this->assertStringContainsString( '', $prompt, 'Prompt should contain goal tag' ); + $this->assertStringContainsString( '', $prompt, 'Prompt should contain closing goal tag' ); + $this->assertStringContainsString( '', $prompt, 'Prompt should contain content tag' ); + $this->assertStringContainsString( '', $prompt, 'Prompt should contain closing content tag' ); + $this->assertStringContainsString( 'Test content here.', $prompt, 'Prompt should contain the original content' ); + } + + /** + * Test that build_prompt() uses different descriptions per action. + * + * @since x.x.x + */ + public function test_build_prompt_varies_by_action() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'build_prompt' ); + $method->setAccessible( true ); + + $shorten_prompt = $method->invoke( $this->ability, 'Test.', 'shorten' ); + $expand_prompt = $method->invoke( $this->ability, 'Test.', 'expand' ); + $rephrase_prompt = $method->invoke( $this->ability, 'Test.', 'rephrase' ); + + $this->assertStringContainsString( 'Condense', $shorten_prompt, 'Shorten prompt should mention condensing' ); + $this->assertStringContainsString( 'Expand', $expand_prompt, 'Expand prompt should mention expanding' ); + $this->assertStringContainsString( 'Rephrase', $rephrase_prompt, 'Rephrase prompt should mention rephrasing' ); + + // All three should be different. + $this->assertNotEquals( $shorten_prompt, $expand_prompt, 'Shorten and expand prompts should differ' ); + $this->assertNotEquals( $shorten_prompt, $rephrase_prompt, 'Shorten and rephrase prompts should differ' ); + $this->assertNotEquals( $expand_prompt, $rephrase_prompt, 'Expand and rephrase prompts should differ' ); + } +} diff --git a/tests/Integration/Includes/Experiments/Content_Resizing/Content_ResizingTest.php b/tests/Integration/Includes/Experiments/Content_Resizing/Content_ResizingTest.php new file mode 100644 index 00000000..a0ce4db4 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Content_Resizing/Content_ResizingTest.php @@ -0,0 +1,104 @@ + 'test-api-key' ) ); + + // Mock has_valid_ai_credentials to return true for tests. + add_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + + // Enable experiments globally and individually. + update_option( 'wpai_features_enabled', true ); + update_option( 'wpai_feature_content-resizing_enabled', true ); + + $registry = new Registry(); + $loader = new Loader( $registry ); + $loader->register_features(); + $loader->initialize_features(); + + $experiment = $registry->get_feature( 'content-resizing' ); + $this->assertInstanceOf( Content_Resizing::class, $experiment, 'Content Resizing experiment should be registered in the registry.' ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'wpai_features_enabled' ); + delete_option( 'wpai_feature_content-resizing_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Test that the experiment is registered correctly. + * + * @since x.x.x + */ + public function test_experiment_registration() { + $experiment = new Content_Resizing(); + + $this->assertEquals( 'content-resizing', $experiment->get_id() ); + $this->assertEquals( 'Content Resizing', $experiment->get_label() ); + $this->assertEquals( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that the experiment can be disabled via filter. + * + * @since x.x.x + */ + public function test_experiment_can_be_disabled() { + add_filter( 'wpai_feature_content-resizing_enabled', '__return_false' ); + + $experiment = new Content_Resizing(); + + $this->assertFalse( $experiment->is_enabled() ); + + remove_filter( 'wpai_feature_content-resizing_enabled', '__return_false' ); + } + + /** + * Test that the experiment metadata is correct. + * + * @since x.x.x + */ + public function test_experiment_metadata() { + $experiment = new Content_Resizing(); + + $this->assertEquals( 'content-resizing', $experiment->get_id() ); + $this->assertNotEmpty( $experiment->get_label(), 'Label should not be empty' ); + $this->assertNotEmpty( $experiment->get_description(), 'Description should not be empty' ); + } +} From 3d3ada69ce33a383986d4c831e4a277248fb14a0 Mon Sep 17 00:00:00 2001 From: Tyler Bailey Date: Tue, 24 Mar 2026 13:00:06 -0400 Subject: [PATCH 8/8] Create SVG variation of the ai plugin icon --- .../components/ContentResizingToolbar.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx index 803a4b51..ad3f6285 100644 --- a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx +++ b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx @@ -34,15 +34,16 @@ const SHORTEN_MIN_WORDS = 5; const ICON_AI = ( - - + + + + + ); @@ -331,13 +332,7 @@ export default function ContentResizingToolbar( { variant="secondary" onClick={ handleRetry } > - { __( 'Retry', 'ai' ) } - -