Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
fb20ab7
Add "Generate with Jetpack" button to Content Guidelines admin page
aagam-shah Mar 13, 2026
676fb4f
Refine Generate with Jetpack button: availability checks and upgrade UX
aagam-shah Mar 17, 2026
e746d2e
Add @since annotation and gate admin-only include behind is_admin()
aagam-shah Mar 17, 2026
6abbb9a
Fix hook suffix to match Gutenberg's registered page slug
aagam-shah Apr 6, 2026
e62ff96
Refactor content-guidelines-ai into component structure
aagam-shah Apr 6, 2026
9cf2b26
Add 'Suggest All' button in Guidelines page header
aagam-shah Apr 6, 2026
f22ec91
Add empty state banner for guideline generation
aagam-shah Apr 6, 2026
329f1c2
Add styles for content guidelines AI components
aagam-shah Apr 6, 2026
4126425
Update changelog for content guidelines AI feature
aagam-shah Apr 6, 2026
24f67d7
Fix header button positioning
aagam-shah Apr 6, 2026
ce21431
Remove per-section generate buttons
aagam-shah Apr 6, 2026
815df29
Clean up PHP docblock
aagam-shah Apr 6, 2026
9f8130c
Add suggestion preview with Accept/Dismiss flow
aagam-shah Apr 6, 2026
f0939cc
Align with exploration-b prototype patterns
aagam-shah Apr 6, 2026
f959b13
Code review fixes and rich empty state banner
aagam-shah Apr 6, 2026
8d16b09
Fix CSS filename — build outputs .css not .min.css
aagam-shah Apr 6, 2026
12dbf80
Use inline Jetpack icon SVG with white fill
aagam-shah Apr 6, 2026
9060c02
Use shared JetpackLogo from jetpack-shared-extension-utils
aagam-shah Apr 6, 2026
16f4b4d
Polish UI: shimmer effect, badge, banner, button visibility
aagam-shah Apr 6, 2026
9c995c2
Use CSS logical properties for RTL support
aagam-shah Apr 6, 2026
db58e49
Add word-level diff view for suggestion review
aagam-shah Apr 6, 2026
7a2f879
Use static import for @wordpress/api-fetch
aagam-shah Apr 6, 2026
5ac59cd
Extract useGenerateAll hook to deduplicate batch generation
aagam-shah Apr 6, 2026
585f805
Add comment explaining DOM class manipulation in suggestion-actions
aagam-shah Apr 6, 2026
eee6def
Document why React roots don't need cleanup in inject.js
aagam-shah Apr 6, 2026
822b184
Add aria-hidden when SuggestAllButton is visually hidden
aagam-shah Apr 6, 2026
4244642
Fix re-injection after Navigator screen transitions
aagam-shah Apr 6, 2026
311841a
Improve injection: unmount old roots, debounce observer
aagam-shah Apr 6, 2026
9b20706
Use top-level diff import instead of deep path
aagam-shah Apr 6, 2026
537282f
Add screen reader context to diff view
aagam-shah Apr 6, 2026
2fee0f5
Fix stylelint errors in style.scss
aagam-shah Apr 6, 2026
b1b8bea
Fix i18n extraction: assign translated strings before ternary
aagam-shah Apr 6, 2026
00050b4
Fix stylelint rule-empty-line-before in @keyframes
aagam-shah Apr 6, 2026
69b2b94
Fix prettier formatting and import order for CI
aagam-shah Apr 6, 2026
606dadc
Fix banner dismiss: persist in localStorage, ignore per-section gener…
aagam-shah Apr 8, 2026
67617bc
Add Close button and X icon to banner
aagam-shah Apr 8, 2026
f0b22db
Improve suggestion UX: green border, click-to-accept, hide badge
aagam-shah Apr 8, 2026
dfe68df
Overlay diff on textarea instead of hiding DataForm
aagam-shah Apr 8, 2026
65af91d
Revert to hiding DataForm approach with better selectors
aagam-shah Apr 8, 2026
bb9fa9b
Match diff container to Gutenberg textarea styling
aagam-shah Apr 8, 2026
7f07ddf
Share banner dismiss state via wp.data store
aagam-shah Apr 8, 2026
0c70895
Update pnpm-lock.yaml after rebase
aagam-shah Apr 8, 2026
dc44b50
Fix banner disappearing on accept + reverse Jetpack logo
aagam-shah Apr 8, 2026
9c49fbe
Make logo triangles transparent so button color shows through
aagam-shah Apr 8, 2026
538033e
Set logo triangles to button accent color
aagam-shah Apr 8, 2026
3b5fe65
Use JetpackLogo from jetpack-components with logoColor="#fff"
aagam-shah Apr 8, 2026
827d332
Diff against current textarea draft, not persisted value
aagam-shah Apr 8, 2026
f3f8659
Fix layout shift when textarea is resized
aagam-shah Apr 8, 2026
1ab60ff
Simplify layout: remove dynamic height sync
aagam-shah Apr 8, 2026
ceb1457
Track textarea height via ResizeObserver for diff container
aagam-shah Apr 8, 2026
7e636d7
Drop ResizeObserver, add resize:vertical to diff container
aagam-shah Apr 8, 2026
2d6cb16
Capture textarea height via useState before hiding
aagam-shah Apr 8, 2026
9ea4169
Update API format to match wpcom endpoint
aagam-shah Apr 8, 2026
9144352
Replace snackbar with persistent upgrade Notice
aagam-shah Apr 8, 2026
427e1b0
Fix upgrade notice width to match accordion cards
aagam-shah Apr 8, 2026
4e65efd
Update pnpm-lock.yaml
aagam-shah Apr 8, 2026
9b3b41f
Hide header button whenever banner is not dismissed
aagam-shah Apr 9, 2026
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
347 changes: 285 additions & 62 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions projects/plugins/jetpack/_inc/content-guidelines-ai.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Content Guidelines AI — Entry point.
*
* Injects Jetpack AI-powered generate/improve buttons into the
* Content Guidelines admin page (Gutenberg experimental feature).
*/
import './content-guidelines-ai/store';
import './content-guidelines-ai/style.scss';
import { startInjection } from './content-guidelines-ai/lib/inject';

if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', startInjection );
} else {
startInjection();
}
92 changes: 92 additions & 0 deletions projects/plugins/jetpack/_inc/content-guidelines-ai.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php
/**
* Content Guidelines AI — Jetpack AI integration.
*
* Enqueues a standalone JS bundle on the Content Guidelines admin page
* that adds AI-powered guideline generation via Jetpack.
*
* @package automattic/jetpack
*/

use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Redirect;
use Automattic\Jetpack\Status;
use Automattic\Jetpack\Status\Host;

if ( ! defined( 'ABSPATH' ) ) {
exit( 0 );
}

/**
* Enqueue content-guidelines-ai script on the Content Guidelines admin page.
*
* @since $$next-version$$
*
* @param string $hook_suffix The current admin page hook suffix.
*/
function jetpack_content_guidelines_ai_enqueue_scripts( $hook_suffix ) {
if ( 'settings_page_guidelines-wp-admin' !== $hook_suffix ) {
return;
}

$asset_file = JETPACK__PLUGIN_DIR . '_inc/build/content-guidelines-ai.min.asset.php';
$asset = file_exists( $asset_file ) ? require $asset_file : array(
'dependencies' => array( 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-element', 'wp-i18n', 'wp-notices' ),
'version' => JETPACK__VERSION,
);

wp_enqueue_script(
'jetpack-content-guidelines-ai',
plugins_url( '_inc/build/content-guidelines-ai.min.js', JETPACK__PLUGIN_FILE ),
$asset['dependencies'],
$asset['version'],
true
);

wp_enqueue_style(
'jetpack-content-guidelines-ai',
plugins_url( '_inc/build/content-guidelines-ai.css', JETPACK__PLUGIN_FILE ),
array( 'wp-components' ),
$asset['version']
);

// Determine AI availability per site type and pass config to JS.
if ( ! class_exists( 'Jetpack_AI_Helper' ) ) {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-helper.php';
}

$host = new Host();
$is_wpcom = $host->is_wpcom_simple() || $host->is_woa_site();
$is_connected = $host->is_wpcom_simple()
|| ( ( new Connection_Manager( 'jetpack' ) )->has_connected_owner() && ! ( new Status() )->is_offline_mode() );
$ai_enabled = \Jetpack_AI_Helper::is_enabled();

$config = array(
'available' => $ai_enabled && $is_connected,
'isConnected' => $is_connected,
);

if ( ! $config['available'] ) {
if ( ! $is_connected ) {
// Self-hosted site not connected to WordPress.com.
$config['upgradeUrl'] = admin_url( 'admin.php?page=jetpack#/dashboard' );
} elseif ( $is_wpcom ) {
// wpcom simple or atomic — needs AI plan.
$config['upgradeUrl'] = Redirect::get_url( 'jetpack-ai-yearly-tier-upgrade-nudge' );
} else {
// Self-hosted connected — needs Jetpack AI product.
$config['upgradeUrl'] = Redirect::get_url( 'jetpack-ai-upgrade-url-for-jetpack-sites' );
}
}

wp_add_inline_script(
'jetpack-content-guidelines-ai',
sprintf(
'window.jetpackContentGuidelinesAiConfig = %s;',
wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP )
),
'before'
);
}

add_action( 'admin_enqueue_scripts', 'jetpack_content_guidelines_ai_enqueue_scripts' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
import useGenerateAll from '../hooks/use-generate-all';
import { AI_STORE_NAME } from '../store';

export default function EmptyStateBanner() {
const { generate } = useGenerateAll();
const { dismissBanner } = useDispatch( AI_STORE_NAME );

const dismissed = useSelect( select => select( AI_STORE_NAME ).isBannerDismissed(), [] );

const handleDismiss = useCallback( () => {
dismissBanner();
}, [ dismissBanner ] );

const handleGetStarted = useCallback( () => {
dismissBanner();
generate();
}, [ dismissBanner, generate ] );

if ( dismissed ) {
return null;
}

return (
<div className="jetpack-content-guidelines-ai__banner">
<div className="jetpack-content-guidelines-ai__banner-content">
<h2>{ __( 'Generate your guidelines in seconds', 'jetpack' ) }</h2>
<p>
{ __(
'Use Jetpack to analyze your site and create draft guidelines based on your actual content.',
'jetpack'
) }
</p>
<div className="jetpack-content-guidelines-ai__banner-actions">
<Button
className="jetpack-content-guidelines-ai__banner-cta"
variant="primary"
onClick={ handleGetStarted }
>
{ __( 'Get started', 'jetpack' ) }
</Button>
<Button
className="jetpack-content-guidelines-ai__banner-close-text"
variant="tertiary"
onClick={ handleDismiss }
>
{ __( 'Close', 'jetpack' ) }
</Button>
</div>
</div>
<Button
className="jetpack-content-guidelines-ai__banner-close"
icon={ closeSmall }
label={ __( 'Dismiss banner', 'jetpack' ) }
size="small"
onClick={ handleDismiss }
/>
<div className="jetpack-content-guidelines-ai__banner-orb jetpack-content-guidelines-ai__banner-orb--top" />
<div className="jetpack-content-guidelines-ai__banner-orb jetpack-content-guidelines-ai__banner-orb--bottom" />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { STORE_NAME } from '../constants';
import { suggestGuidelines } from '../lib/api';
import { showUnavailableNotice } from '../lib/availability';
import { AI_STORE_NAME } from '../store';

export default function SectionGenerateButton( { slug } ) {
const { createErrorNotice } = useDispatch( noticesStore );
const { startSectionLoading, stopSectionLoading, setSuggestion } = useDispatch( AI_STORE_NAME );

const sectionLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( slug ),
[ slug ]
);
const draft = useSelect( select => select( STORE_NAME ).getGuideline( slug ), [ slug ] );

const isEmpty = ! draft;
const generateLabel = __( 'Generate guidelines', 'jetpack' );
const improveLabel = __( 'Improve guidelines', 'jetpack' );
const label = isEmpty ? generateLabel : improveLabel;

const handleClick = useCallback( async () => {
if ( showUnavailableNotice() ) {
return;
}

startSectionLoading( slug );
try {
const existingContent = draft ? { [ slug ]: draft } : {};
const response = await suggestGuidelines( [ slug ], existingContent );
const suggestion = response?.suggestions?.[ slug ];
if ( suggestion ) {
setSuggestion( slug, suggestion );
}
} catch {
createErrorNotice( __( 'Failed to generate guidelines. Please try again.', 'jetpack' ), {
type: 'snackbar',
} );
} finally {
stopSectionLoading( slug );
}
}, [ slug, draft, startSectionLoading, stopSectionLoading, setSuggestion, createErrorNotice ] );

return (
<Button
variant="tertiary"
onClick={ handleClick }
disabled={ sectionLoading }
accessibleWhenDisabled
className="jetpack-content-guidelines-ai__section-generate-button"
>
{ label }
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { JetpackLogo } from '@automattic/jetpack-components';
import { Button } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { STORE_NAME, VALID_SECTIONS } from '../constants';
import useGenerateAll from '../hooks/use-generate-all';
import { AI_STORE_NAME } from '../store';

export default function SuggestAllButton() {
const { generate, loading } = useGenerateAll();

const bannerDismissed = useSelect( select => select( AI_STORE_NAME ).isBannerDismissed(), [] );

const allGuidelines = useSelect( select => {
const store = select( STORE_NAME );
return Object.fromEntries( VALID_SECTIONS.map( slug => [ slug, store.getGuideline( slug ) ] ) );
}, [] );

const allEmpty = VALID_SECTIONS.every( slug => ! allGuidelines[ slug ] );

const generateLabel = __( 'Generate guidelines', 'jetpack' );
const improveLabel = __( 'Improve guidelines', 'jetpack' );
const label = allEmpty ? generateLabel : improveLabel;

// Hide when the banner is visible (not yet dismissed).
const hiddenProps = ! bannerDismissed ? { style: { display: 'none' }, 'aria-hidden': true } : {};

return (
<Button
{ ...hiddenProps }
variant="primary"
icon={ <JetpackLogo showText={ false } height={ 18 } logoColor="#fff" /> }
onClick={ generate }
disabled={ loading }
accessibleWhenDisabled
isBusy={ loading }
className="jetpack-content-guidelines-ai__suggest-all-button"
>
{ label }
</Button>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { diffWords } from 'diff';
import { STORE_NAME } from '../constants';
import { AI_STORE_NAME } from '../store';

export default function SuggestionActions( { slug } ) {
const suggestion = useSelect( select => select( AI_STORE_NAME ).getSuggestion( slug ), [ slug ] );
const sectionLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( slug ),
[ slug ]
);
const { clearSuggestion } = useDispatch( AI_STORE_NAME );
const { setGuideline } = useDispatch( STORE_NAME );

const [ original, setOriginal ] = useState( '' );
const [ textareaHeight, setTextareaHeight ] = useState( null );

// Direct DOM class manipulation is necessary because this component is rendered in
// a separate React root injected into Gutenberg's page — we can't control classes
// on Gutenberg-owned elements through React props.
useEffect( () => {
const form = document.getElementById( `content-guidelines-${ slug }` );
if ( ! form ) {
return;
}

// Capture textarea draft and height before hiding it.
if ( suggestion && ! form.classList.contains( 'has-jetpack-suggestion' ) ) {
const textarea = form.querySelector( 'textarea' );
if ( textarea ) {
setOriginal( textarea.value || '' );
if ( textarea.offsetHeight > 0 ) {
setTextareaHeight( textarea.offsetHeight );
}
}
}

form.classList.toggle( 'has-jetpack-suggestion', !! suggestion );
form.classList.toggle( 'is-jetpack-loading', sectionLoading && ! suggestion );
return () => {
form.classList.remove( 'has-jetpack-suggestion', 'is-jetpack-loading' );
};
}, [ slug, suggestion, sectionLoading ] );

const diff = useMemo( () => {
if ( ! suggestion ) {
return [];
}
return diffWords( original, suggestion );
}, [ original, suggestion ] );

const handleAccept = useCallback( () => {
setGuideline( slug, suggestion );
clearSuggestion( slug );
}, [ slug, suggestion, setGuideline, clearSuggestion ] );

const handleDismiss = useCallback( () => {
clearSuggestion( slug );
}, [ slug, clearSuggestion ] );

const handleKeyDown = useCallback(
e => {
if ( e.key === 'Enter' || e.key === ' ' ) {
e.preventDefault();
handleAccept();
}
},
[ handleAccept ]
);

if ( ! suggestion ) {
return null;
}

return (
<div className="jetpack-content-guidelines-ai__suggestion">
<div
className="jetpack-content-guidelines-ai__diff"
style={ textareaHeight ? { height: textareaHeight } : undefined }
role="button"
tabIndex={ 0 }
aria-label={ __( 'Click to accept suggested changes', 'jetpack' ) }
onClick={ handleAccept }
onKeyDown={ handleKeyDown }
>
<span className="screen-reader-text">
{ __( 'Changes from current to suggested guidelines:', 'jetpack' ) }
</span>
{ diff.map( ( part, i ) => {
if ( part.added ) {
return (
<ins key={ i } className="jetpack-content-guidelines-ai__diff-added">
{ part.value }
</ins>
);
}
if ( part.removed ) {
return (
<del key={ i } className="jetpack-content-guidelines-ai__diff-removed">
{ part.value }
</del>
);
}
return <span key={ i }>{ part.value }</span>;
} ) }
</div>
<div className="jetpack-content-guidelines-ai__suggestion-actions">
<Button variant="primary" onClick={ handleAccept }>
{ __( 'Accept suggestion', 'jetpack' ) }
</Button>
<Button variant="tertiary" onClick={ handleDismiss }>
{ __( 'Dismiss', 'jetpack' ) }
</Button>
</div>
</div>
);
}
Loading
Loading