This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,73 @@
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { acceptBlockSuggestion } from '../lib/dom';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
import DiffView from './diff-view';
// Renders only the diff view. Accept/Dismiss and Improve buttons live in
// BlockSuggestionButtons, injected as a separate row above the modal action bar.
export default function BlockSuggestionActions( { blockName, blockModal } ) {
const suggestion = useSelect(
select => select( AI_STORE_NAME ).getSuggestion( blockName ),
[ blockName ]
);
const blockLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( blockName ),
[ blockName ]
);
const { clearSuggestion } = useDispatch( AI_STORE_NAME );
const [ original, setOriginal ] = useState( '' );
const [ textareaHeight, setTextareaHeight ] = useState( null );
// Clear stale suggestion when the modal closes (component unmounts).
useEffect( () => {
return () => clearSuggestion( blockName );
}, [ blockName, clearSuggestion ] );
// Toggle shimmer and suggestion classes on the modal.
useEffect( () => {
if ( ! blockModal ) {
return;
}
// Capture textarea content and height before hiding it.
if ( suggestion && ! blockModal.classList.contains( 'has-jetpack-suggestion' ) ) {
const textarea = blockModal.querySelector( '.components-textarea-control__input' );
if ( textarea ) {
setOriginal( textarea.value || '' );
if ( textarea.offsetHeight > 0 ) {
setTextareaHeight( textarea.offsetHeight );
} else {
const rows = parseInt( textarea.getAttribute( 'rows' ), 10 ) || 6;
setTextareaHeight( rows * 20 + 20 );
}
}
}
blockModal.classList.toggle( 'has-jetpack-suggestion', !! suggestion );
blockModal.classList.toggle( 'is-jetpack-loading', blockLoading && ! suggestion );
return () => {
blockModal.classList.remove( 'has-jetpack-suggestion', 'is-jetpack-loading' );
};
}, [ blockModal, suggestion, blockLoading ] );
const handleAccept = useCallback( () => {
recordGuidelinesEvent( 'accept', { type: 'block', slug: blockName } );
acceptBlockSuggestion( blockModal, blockName, suggestion, clearSuggestion );
}, [ blockModal, blockName, suggestion, clearSuggestion ] );
if ( ! suggestion ) {
return null;
}
return (
<DiffView
original={ original }
suggestion={ suggestion }
onAccept={ handleAccept }
height={ textareaHeight }
/>
);
}
@@ -0,0 +1,107 @@
import { useAiFeature } from '@automattic/jetpack-ai-client';
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 { acceptBlockSuggestion } from '../lib/dom';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
export default function BlockSuggestionButtons( { blockName, blockModal } ) {
const { createErrorNotice } = useDispatch( noticesStore );
const { startSectionLoading, stopSectionLoading, setSuggestion, clearSuggestion } =
useDispatch( AI_STORE_NAME );
const { hasFeature } = useAiFeature();
const blockLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( blockName ),
[ blockName ]
);
const suggestion = useSelect(
select => select( AI_STORE_NAME ).getSuggestion( blockName ),
[ blockName ]
);
const saved = useSelect(
select => select( STORE_NAME ).getBlockGuideline( blockName ),
[ blockName ]
);
const handleGenerate = useCallback( async () => {
const action = saved ? 'improve' : 'generate';
recordGuidelinesEvent( 'generate', { type: 'block', slug: blockName, action } );
const textarea = blockModal?.querySelector( '.components-textarea-control__input' );
const currentText = textarea?.value || '';
startSectionLoading( blockName );
try {
const existingContent = currentText ? { [ blockName ]: currentText } : {};
const response = await suggestGuidelines( [ blockName ], existingContent );
const text = response?.suggestions?.[ blockName ];
if ( ! text ) {
throw new Error( 'No suggestion returned.' );
}
setSuggestion( blockName, text );
} catch {
createErrorNotice( __( 'Failed to generate guidelines. Please try again.', 'jetpack' ), {
type: 'snackbar',
} );
} finally {
stopSectionLoading( blockName );
}
}, [
blockModal,
blockName,
saved,
startSectionLoading,
stopSectionLoading,
setSuggestion,
createErrorNotice,
] );
const handleAccept = useCallback( () => {
recordGuidelinesEvent( 'accept', { type: 'block', slug: blockName } );
acceptBlockSuggestion( blockModal, blockName, suggestion, clearSuggestion );
}, [ blockModal, blockName, suggestion, clearSuggestion ] );
const handleDismiss = useCallback( () => {
recordGuidelinesEvent( 'dismiss', { type: 'block', slug: blockName } );
clearSuggestion( blockName );
}, [ blockName, clearSuggestion ] );
if ( suggestion ) {
return (
<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>
);
}
const generateLabel = __( 'Generate guidelines', 'jetpack' );
const improveLabel = __( 'Improve guidelines', 'jetpack' );
const label = saved ? improveLabel : generateLabel;
return (
<div className="jetpack-content-guidelines-ai__suggestion-actions">
<Button
variant="secondary"
onClick={ handleGenerate }
disabled={ blockLoading || ! hasFeature }
accessibleWhenDisabled
className="jetpack-content-guidelines-ai__section-generate-button"
>
{ label }
</Button>
</div>
);
}
@@ -0,0 +1,55 @@
import { useCallback, useMemo } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { diffWords } from 'diff';
export default function DiffView( { original, suggestion, onAccept, height } ) {
const diff = useMemo( () => {
if ( ! suggestion ) {
return [];
}
return diffWords( original, suggestion );
}, [ original, suggestion ] );
const handleKeyDown = useCallback(
e => {
if ( e.key === 'Enter' || e.key === ' ' ) {
e.preventDefault();
onAccept();
}
},
[ onAccept ]
);
return (
<div
className="jetpack-content-guidelines-ai__diff"
style={ height ? { height } : undefined }
role="button"
tabIndex={ 0 }
aria-label={ __( 'Click to accept suggested changes', 'jetpack' ) }
onClick={ onAccept }
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>
);
}
@@ -0,0 +1,68 @@
import { useAiFeature } from '@automattic/jetpack-ai-client';
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 { hasFeature } = useAiFeature();
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 || ! hasFeature ) {
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>
);
}
@@ -0,0 +1,69 @@
import { useAiFeature } from '@automattic/jetpack-ai-client';
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 { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
export default function SectionGenerateButton( { slug } ) {
const { createErrorNotice } = useDispatch( noticesStore );
const { startSectionLoading, stopSectionLoading, setSuggestion } = useDispatch( AI_STORE_NAME );
const { hasFeature } = useAiFeature();
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 () => {
const action = isEmpty ? 'generate' : 'improve';
recordGuidelinesEvent( 'generate', { type: 'section', slug, action } );
startSectionLoading( slug );
try {
const existingContent = draft ? { [ slug ]: draft } : {};
const response = await suggestGuidelines( [ slug ], existingContent );
const suggestion = response?.suggestions?.[ slug ];
if ( ! suggestion ) {
throw new Error( 'No suggestion returned.' );
}
setSuggestion( slug, suggestion );
} catch {
createErrorNotice( __( 'Failed to generate guidelines. Please try again.', 'jetpack' ), {
type: 'snackbar',
} );
} finally {
stopSectionLoading( slug );
}
}, [
slug,
draft,
isEmpty,
startSectionLoading,
stopSectionLoading,
setSuggestion,
createErrorNotice,
] );
return (
<Button
variant="tertiary"
onClick={ handleClick }
disabled={ sectionLoading || ! hasFeature }
accessibleWhenDisabled
className="jetpack-content-guidelines-ai__section-generate-button"
>
{ label }
</Button>
);
}
@@ -0,0 +1,43 @@
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, hasFeature } = 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) or user lacks AI plan.
const hidden = ! bannerDismissed || ! hasFeature;
const hiddenProps = hidden ? { style: { display: 'none' }, 'aria-hidden': true } : {};
return (
<Button
{ ...hiddenProps }
variant="primary"
icon={ <JetpackLogo showText={ false } height={ 18 } logoColor="#fff" /> }
onClick={ generate }
disabled={ loading || ! hasFeature }
accessibleWhenDisabled
isBusy={ loading }
className="jetpack-content-guidelines-ai__suggest-all-button"
>
{ label }
</Button>
);
}
@@ -0,0 +1,89 @@
import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { STORE_NAME } from '../constants';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
import DiffView from './diff-view';
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 item = document.querySelector( `.guidelines__list-item[data-slug="${ slug }"]` );
const form = item?.querySelector( 'form' );
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 );
} else {
// Fallback when textarea is hidden (e.g. collapsed accordion).
// Compute height from rows attribute to match the textarea.
const rows = parseInt( textarea.getAttribute( 'rows' ), 10 ) || 4;
// line-height: 20px, padding: 9px top + 9px bottom, border: 1px + 1px.
setTextareaHeight( rows * 20 + 20 );
}
}
}
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 handleAccept = useCallback( () => {
recordGuidelinesEvent( 'accept', { type: 'section', slug } );
setGuideline( slug, suggestion );
clearSuggestion( slug );
}, [ slug, suggestion, setGuideline, clearSuggestion ] );
const handleDismiss = useCallback( () => {
recordGuidelinesEvent( 'dismiss', { type: 'section', slug } );
clearSuggestion( slug );
}, [ slug, clearSuggestion ] );
if ( ! suggestion ) {
return null;
}
return (
<div className="jetpack-content-guidelines-ai__suggestion">
<DiffView
original={ original }
suggestion={ suggestion }
onAccept={ handleAccept }
height={ textareaHeight }
/>
<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>
);
}
@@ -0,0 +1,30 @@
import { Spinner } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { Badge } from '@wordpress/ui';
import { AI_STORE_NAME } from '../store';
export default function SuggestionBadge( { slug } ) {
const sectionLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( slug ),
[ slug ]
);
const hasSuggestion = useSelect(
select => select( AI_STORE_NAME ).hasSuggestion( slug ),
[ slug ]
);
if ( sectionLoading && ! hasSuggestion ) {
return (
<span className="jetpack-content-guidelines-ai__badge--loading">
<Spinner />
</span>
);
}
if ( hasSuggestion ) {
return <Badge intent="stable">{ __( 'Suggestion', 'jetpack' ) }</Badge>;
}
return null;
}
@@ -0,0 +1,52 @@
import { useAICheckout, useAiFeature } from '@automattic/jetpack-ai-client';
import { Button, Notice } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { recordAiEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
export default function UpgradeNotice() {
const { hasFeature } = useAiFeature();
const { checkoutUrl } = useAICheckout();
const { dismissBanner } = useDispatch( AI_STORE_NAME );
const dismissed = useSelect( select => select( AI_STORE_NAME ).isBannerDismissed(), [] );
const handleUpgradeClick = useCallback( () => {
// Record the click only. Dismissal is persisted by the close button
// ( onRemove ), so the nudge returns if checkout is opened and abandoned.
recordAiEvent( 'jetpack_ai_upgrade_button', {
placement: 'content-guidelines',
} );
}, [] );
if ( hasFeature || dismissed ) {
return null;
}
return (
<Notice
status="success"
isDismissible
onRemove={ dismissBanner }
className="jetpack-content-guidelines-ai__upgrade-notice"
>
<p>
{ __(
'Not sure where to start? Jetpack can read your site and suggest guidelines tailored to your content. Upgrade to get started.',
'jetpack'
) }
</p>
{ checkoutUrl && (
<Button
variant="primary"
href={ checkoutUrl }
target="_blank"
onClick={ handleUpgradeClick }
>
{ __( 'Upgrade', 'jetpack' ) }
</Button>
) }
</Notice>
);
}
@@ -0,0 +1,3 @@
export const STORE_NAME = 'core/guidelines';
export const VALID_SECTIONS = [ 'site', 'copy', 'images', 'additional' ];
export const API_PATH = '/wpcom/v2/jetpack-ai/suggest-guidelines';
@@ -0,0 +1,66 @@
import { useAiFeature } from '@automattic/jetpack-ai-client';
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, VALID_SECTIONS } from '../constants';
import { suggestGuidelines } from '../lib/api';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
/**
* Hook that returns a callback to generate suggestions for all sections.
*
* @return {{ generate: Function, loading: boolean }} Generate callback and loading state.
*/
export default function useGenerateAll() {
const { createErrorNotice } = useDispatch( noticesStore );
const { startLoading, stopLoading, setSuggestion } = useDispatch( AI_STORE_NAME );
const loading = useSelect( select => select( AI_STORE_NAME ).isLoading(), [] );
const { hasFeature } = useAiFeature();
const allGuidelines = useSelect( select => {
const store = select( STORE_NAME );
return Object.fromEntries( VALID_SECTIONS.map( slug => [ slug, store.getGuideline( slug ) ] ) );
}, [] );
const generate = useCallback( async () => {
if ( ! hasFeature ) {
return;
}
const allEmpty = VALID_SECTIONS.every( slug => ! allGuidelines[ slug ] );
recordGuidelinesEvent( 'generate_all', {
action: allEmpty ? 'generate' : 'improve',
} );
startLoading();
try {
const existingContent = Object.fromEntries(
VALID_SECTIONS.filter( slug => allGuidelines[ slug ] ).map( slug => [
slug,
allGuidelines[ slug ],
] )
);
const response = await suggestGuidelines( VALID_SECTIONS, existingContent );
const suggestions = response?.suggestions || {};
const appliedSlugs = VALID_SECTIONS.filter( slug => suggestions[ slug ] );
// No usable suggestions came back — surface it like any other failure.
if ( appliedSlugs.length === 0 ) {
throw new Error( 'No suggestions returned.' );
}
appliedSlugs.forEach( slug => setSuggestion( slug, suggestions[ slug ] ) );
} catch {
createErrorNotice( __( 'Failed to generate guidelines. Please try again.', 'jetpack' ), {
type: 'snackbar',
} );
} finally {
stopLoading();
}
}, [ hasFeature, allGuidelines, startLoading, stopLoading, setSuggestion, createErrorNotice ] );
return { generate, loading, hasFeature };
}
@@ -0,0 +1,61 @@
import apiFetch from '@wordpress/api-fetch';
import { API_PATH, VALID_SECTIONS } from '../constants';
/**
* Generate or improve guidelines for the given sections/blocks.
*
* Translates between the internal format used by our components and the
* API's categories-based format:
*
* API request: { categories: { site: {}, copy: { guidelines: "..." }, blocks: { "core/paragraph": {} } } }
* API response: { site: { guidelines: "..." }, blocks: { "core/paragraph": { guidelines: "..." } } }
*
* @param {string[]} slugs - Section slugs or block names to generate.
* @param {Object.<string, string>} [existingContent] - Existing content keyed by slug.
* @return {Promise<Object>} Response with `suggestions` keyed by slug.
*/
export async function suggestGuidelines( slugs, existingContent = {} ) {
// Build categories object for the API.
// Standard sections go as top-level keys, block names go under `blocks`.
const categories = {};
const blockEntries = {};
for ( const slug of slugs ) {
const existing = existingContent[ slug ];
const entry = existing ? { guidelines: existing } : {};
if ( VALID_SECTIONS.includes( slug ) ) {
categories[ slug ] = entry;
} else {
blockEntries[ slug ] = entry;
}
}
if ( Object.keys( blockEntries ).length > 0 ) {
categories.blocks = blockEntries;
}
const response = await apiFetch( {
path: API_PATH,
method: 'POST',
data: { categories },
} );
// Normalize API response to { suggestions: { slug: text } }.
const suggestions = {};
for ( const slug of slugs ) {
if ( VALID_SECTIONS.includes( slug ) ) {
const guidelines = response?.[ slug ]?.guidelines;
if ( guidelines ) {
suggestions[ slug ] = guidelines;
}
} else {
const guidelines = response?.blocks?.[ slug ]?.guidelines;
if ( guidelines ) {
suggestions[ slug ] = guidelines;
}
}
}
return { suggestions };
}
@@ -0,0 +1,31 @@
/**
* Programmatically set a React-controlled textarea's value.
* Uses the native setter so React's synthetic onChange fires.
*
* @param {HTMLTextAreaElement} textarea - The textarea element.
* @param {string} value - The new value.
*/
export function setTextareaValue( textarea, value ) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set;
setter.call( textarea, value );
textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) );
}
/**
* Accept a block suggestion: write text to the modal textarea and clear the store.
*
* @param {HTMLElement} blockModal - The block guideline modal element.
* @param {string} blockName - Block name key in the store.
* @param {string} suggestion - Suggestion text to write.
* @param {Function} clearSuggestion - Store action to clear the suggestion.
*/
export function acceptBlockSuggestion( blockModal, blockName, suggestion, clearSuggestion ) {
const textarea = blockModal?.querySelector( '.components-textarea-control__input' );
if ( textarea ) {
setTextareaValue( textarea, suggestion );
}
clearSuggestion( blockName );
}
@@ -0,0 +1,382 @@
import { select } from '@wordpress/data';
import { createRoot, createElement } from '@wordpress/element';
import BlockSuggestionActions from '../components/block-suggestion-actions';
import BlockSuggestionButtons from '../components/block-suggestion-buttons';
import EmptyStateBanner from '../components/empty-state-banner';
import SectionGenerateButton from '../components/section-generate-button';
import SuggestAllButton from '../components/suggest-all-button';
import SuggestionActions from '../components/suggestion-actions';
import SuggestionBadge from '../components/suggestion-badge';
import UpgradeNotice from '../components/upgrade-notice';
import { VALID_SECTIONS } from '../constants';
// Each injection point tracks both the DOM container and its React root.
// Before re-injecting, we unmount the old root to ensure proper cleanup
// of effects and subscriptions. We verify containers via isConnected since
// Gutenberg's <Navigator> removes/re-adds the main screen DOM when
// navigating to revision history and back.
const slots = {
header: { container: null, root: null },
'upgrade-notice': { container: null, root: null },
banner: { container: null, root: null },
};
for ( const slug of VALID_SECTIONS ) {
slots[ `badge-${ slug }` ] = { container: null, root: null };
slots[ `actions-${ slug }` ] = { container: null, root: null };
slots[ `button-${ slug }` ] = { container: null, root: null };
}
slots[ 'block-actions' ] = { container: null, root: null };
slots[ 'block-suggestion-buttons' ] = { container: null, root: null };
// Block name the block-modal slots were last rendered with. Lets runAll()
// detect when the create-mode combobox switches to a different block while
// the slots are still mounted.
let lastBlockName = null;
/**
* Inject a React component into the DOM, reusing or replacing the slot.
*
* @param {string} key - Slot key in the slots map.
* @param {Function} findParent - Returns { parent, before, className } or null.
* @param {Function} Component - React component to render.
* @param {Object} [props] - Props to pass to the component.
*/
function inject( key, findParent, Component, props ) {
const slot = slots[ key ];
// Already injected and still in DOM — nothing to do.
if ( slot.container?.isConnected ) {
return;
}
// Container was removed — unmount the old root to clean up effects.
if ( slot.root ) {
slot.root.unmount();
slot.root = null;
slot.container = null;
}
const target = findParent();
if ( ! target ) {
return;
}
const { parent, before, className, tag } = target;
const container = document.createElement( tag || 'div' );
container.className = className;
if ( before ) {
parent.insertBefore( container, before );
} else {
parent.appendChild( container );
}
const root = createRoot( container );
root.render( createElement( Component, props ) );
slot.container = container;
slot.root = root;
}
/**
* Unmount a slot's React root and remove its container from the DOM.
*
* Unlike inject()'s cleanup path, the container may still be connected —
* used when a slot must re-render with different props (e.g. the block
* modal's combobox switching blocks).
*
* @param {string} key - Slot key in the slots map.
*/
function unmountSlot( key ) {
const slot = slots[ key ];
if ( slot.root ) {
slot.root.unmount();
slot.root = null;
}
if ( slot.container ) {
slot.container.remove();
slot.container = null;
}
}
/**
* Extract the block name from the block guideline modal.
* In editing mode, reads the disabled TextControl and matches against block types.
* In creating mode, reads the ComboboxControl's selected value.
*/
function getBlockNameFromModal( modal ) {
const blockTypes = select( 'core/blocks' ).getBlockTypes();
// Editing mode: disabled input shows block title.
const disabledInput = modal.querySelector( 'input[disabled]' );
if ( disabledInput?.value ) {
return blockTypes.find( b => b.title === disabledInput.value )?.name;
}
// Creating mode: combobox with selected value.
const combobox = modal.querySelector( 'input[role="combobox"]' );
if ( combobox?.value ) {
return blockTypes.find( b => b.title === combobox.value )?.name;
}
return null;
}
function runAll() {
// Header button — right-aligned in the wp-admin Page header, where the
// native header actions would render. The gutenberg page passes no
// `actions` to <Page>, so that slot is never created; instead we target the
// header-content row (flex, justify: space-between) that holds the title and
// append the button as its second child so space-between pushes it to the
// right. All header classes are hashed CSS-module names, so we locate the
// row structurally: the space-between flex row containing the page <h1>.
inject(
'header',
() => {
// Wait until the guidelines have loaded before mounting the button.
// Gutenberg renders the list only after its async fetch resolves
// (a spinner shows until then); mounting earlier reads the empty
// default store and flickers the label "Generate" -> "Improve".
if ( ! document.querySelector( '.guidelines__list' ) ) {
return null;
}
const region = document.querySelector( '.admin-ui-navigable-region' );
const heading = region?.querySelector( 'h1' );
let row = heading?.parentElement;
while ( row && row !== region ) {
const style = window.getComputedStyle( row );
if (
style.display === 'flex' &&
style.flexDirection === 'row' &&
style.justifyContent.includes( 'between' )
) {
break;
}
row = row.parentElement;
}
return row && row !== region
? {
parent: row,
className: 'jetpack-content-guidelines-ai__header-container',
}
: null;
},
SuggestAllButton
);
// Upgrade notice — shown above the guideline list when AI is unavailable.
inject(
'upgrade-notice',
() => {
const list = document.querySelector( '.guidelines__list' );
return list
? {
parent: list.parentElement,
before: list,
className: 'jetpack-content-guidelines-ai__upgrade-notice-container',
}
: null;
},
UpgradeNotice
);
// Empty state banner.
inject(
'banner',
() => {
const list = document.querySelector( '.guidelines__list' );
return list
? {
parent: list.parentElement,
before: list,
className: 'jetpack-content-guidelines-ai__banner-container',
}
: null;
},
EmptyStateBanner
);
// Per-section injections. Sections are matched by the stable `data-slug`
// attribute on each `.guidelines__list-item`. The per-section form is
// always present in the DOM (the CollapsibleCard keeps its content mounted
// while collapsed).
for ( const slug of VALID_SECTIONS ) {
// Steady-state fast path: once this section's three slots are injected
// and still connected, there is nothing to do — skip the DOM queries so
// the observer's per-frame work stays cheap. If the <Navigator> removes
// the screen, the containers disconnect and we fall through to re-inject.
if (
slots[ `badge-${ slug }` ].container?.isConnected &&
slots[ `actions-${ slug }` ].container?.isConnected &&
slots[ `button-${ slug }` ].container?.isConnected
) {
continue;
}
const item = document.querySelector( `.guidelines__list-item[data-slug="${ slug }"]` );
const form = item?.querySelector( 'form' );
if ( ! form ) {
continue;
}
// Badge in the accordion header (the section's heading element).
inject(
`badge-${ slug }`,
() => {
const heading = item.querySelector( 'h1, h2, h3, h4, h5, h6' );
const titleStack = heading?.firstElementChild ?? heading;
return titleStack
? {
parent: titleStack,
className: 'jetpack-content-guidelines-ai__badge-container',
tag: 'span',
}
: null;
},
SuggestionBadge,
{ slug }
);
// Suggestion actions (diff + accept/dismiss) at top of form.
inject(
`actions-${ slug }`,
() => {
const vStack = form.firstElementChild;
return vStack
? {
parent: vStack,
before: vStack.firstChild,
className: 'jetpack-content-guidelines-ai__actions-container',
}
: null;
},
SuggestionActions,
{ slug }
);
// Per-section generate button next to the Save button (the form's
// primary submit button lives in an HStack with the Clear button).
inject(
`button-${ slug }`,
() => {
const saveButton = form.querySelector( 'button[type="submit"]' );
const hStack = saveButton?.parentElement;
return hStack
? {
parent: hStack,
className: 'jetpack-content-guidelines-ai__section-button-container',
}
: null;
},
SectionGenerateButton,
{ slug }
);
}
// Block guideline modal injections.
const blockModal = document.querySelector( '.block-guideline-modal' );
// Resolve the block name on every pass while the modal is open — even when
// the slots are already mounted. In create mode the user can switch the
// combobox to a different block after our components rendered, and
// blockName is bound as a prop at render time. When the resolved name
// changes, tear the mounted slots down so they re-inject below with the
// new name; otherwise a suggestion generated for the previous block would
// be accepted into the newly selected one. Unmounting also runs
// BlockSuggestionActions' cleanup, clearing the stale suggestion from the
// store.
const blockName = blockModal ? getBlockNameFromModal( blockModal ) : null;
if ( blockName !== lastBlockName ) {
unmountSlot( 'block-actions' );
unmountSlot( 'block-suggestion-buttons' );
lastBlockName = blockName;
}
// Always invoke inject so previously mounted roots get unmounted when
// the modal closes — findParent() returns null for "no place to inject"
// and inject()'s cleanup path handles the disconnected container.
inject(
'block-actions',
() => {
if ( ! blockName || ! blockModal ) {
return null;
}
const textareaInput = blockModal.querySelector( '.components-textarea-control__input' );
const field = textareaInput?.parentElement;
return field
? {
parent: field,
before: textareaInput,
className: 'jetpack-content-guidelines-ai__block-actions-container',
}
: null;
},
BlockSuggestionActions,
{ blockName, blockModal }
);
inject(
'block-suggestion-buttons',
() => {
if ( ! blockName || ! blockModal ) {
return null;
}
const actionsBar = blockModal.querySelector( '.block-guideline-modal__actions' );
const vStack = actionsBar?.parentElement;
return vStack
? {
parent: vStack,
before: actionsBar,
className: 'jetpack-content-guidelines-ai__block-suggestion-buttons-container',
}
: null;
},
BlockSuggestionButtons,
{ blockName, blockModal }
);
}
/**
* Start observing DOM and inject all components.
*
* We observe document.body (not a narrower container) for two reasons:
* 1. WordPress Modal portals render directly on document.body — the block
* guideline modal lives outside any Gutenberg container, so a narrower
* root would miss it appearing.
* 2. Gutenberg's Navigator can remove and re-add the main screen DOM
* (e.g. revision history navigation), so we can't rely on a specific
* container staying connected.
*
* The observer never disconnects for the same reasons. Callbacks are
* debounced via requestAnimationFrame so runAll() fires at most once
* per frame, and each inject() call is a no-op when its container is
* still connected.
*/
let injectionStarted = false;
export function startInjection() {
if ( injectionStarted ) {
return;
}
injectionStarted = true;
runAll();
let scheduled = false;
const observer = new MutationObserver( () => {
if ( ! scheduled ) {
scheduled = true;
requestAnimationFrame( () => {
scheduled = false;
runAll();
} );
}
} );
observer.observe( document.body, { childList: true, subtree: true } );
}
@@ -0,0 +1,22 @@
import analytics from '@automattic/jetpack-analytics';
/**
* Record a content guidelines Tracks event.
*
* @param {string} eventName - Event name suffix (appended to `jetpack_ai_guidelines_`).
* @param {Object} properties - Event properties.
*/
export function recordGuidelinesEvent( eventName, properties = {} ) {
analytics.tracks.recordEvent( `jetpack_ai_guidelines_${ eventName }`, properties );
}
/**
* Record a generic Jetpack AI Tracks event.
* Use for shared events like `jetpack_ai_upgrade_button`.
*
* @param {string} eventName - Full event name.
* @param {Object} properties - Event properties.
*/
export function recordAiEvent( eventName, properties = {} ) {
analytics.tracks.recordEvent( eventName, properties );
}
@@ -0,0 +1,112 @@
import apiFetch from '@wordpress/api-fetch';
import { createReduxStore, register } from '@wordpress/data';
// Per-user dismissed flag, persisted server-side so it follows the user across
// devices/browsers. The initial value is preloaded into the page by PHP (see
// _inc/content-guidelines-ai.php); dismissal is written via this REST route.
const DISMISS_PATH = '/wpcom/v2/jetpack-ai/guidelines-banner-dismissed';
const DEFAULT_STATE = {
loading: false,
loadingSections: {},
suggestions: {},
bannerDismissed: !! window.jetpackContentGuidelinesAi?.bannerDismissed,
};
const actions = {
startLoading() {
return { type: 'START_LOADING' };
},
stopLoading() {
return { type: 'STOP_LOADING' };
},
setSuggestion( slug, text ) {
return { type: 'SET_SUGGESTION', slug, text };
},
clearSuggestion( slug ) {
return { type: 'CLEAR_SUGGESTION', slug };
},
startSectionLoading( slug ) {
return { type: 'START_SECTION_LOADING', slug };
},
stopSectionLoading( slug ) {
return { type: 'STOP_SECTION_LOADING', slug };
},
dismissBanner() {
return ( { select, dispatch } ) => {
// One-way flag — skip the write if already dismissed.
if ( select.isBannerDismissed() ) {
return;
}
// Optimistic: update state now, persist per-user in the background.
// A failed write is ignored — re-syncs from the preloaded value on
// the next page load.
dispatch( { type: 'DISMISS_BANNER' } );
apiFetch( {
method: 'PUT',
path: DISMISS_PATH,
} ).catch( () => {} );
};
},
};
function reducer( state = DEFAULT_STATE, action ) {
switch ( action.type ) {
case 'START_LOADING':
return { ...state, loading: true };
case 'STOP_LOADING':
return { ...state, loading: false };
case 'SET_SUGGESTION':
return {
...state,
suggestions: { ...state.suggestions, [ action.slug ]: action.text },
};
case 'CLEAR_SUGGESTION': {
const suggestions = { ...state.suggestions };
delete suggestions[ action.slug ];
return { ...state, suggestions };
}
case 'DISMISS_BANNER':
return { ...state, bannerDismissed: true };
case 'START_SECTION_LOADING':
return {
...state,
loadingSections: { ...state.loadingSections, [ action.slug ]: true },
};
case 'STOP_SECTION_LOADING': {
const loadingSections = { ...state.loadingSections };
delete loadingSections[ action.slug ];
return { ...state, loadingSections };
}
default:
return state;
}
}
const selectors = {
isLoading( state ) {
return state.loading;
},
getSuggestion( state, slug ) {
return state.suggestions[ slug ] || '';
},
hasSuggestion( state, slug ) {
return !! state.suggestions[ slug ];
},
isSectionLoading( state, slug ) {
return state.loading || !! state.loadingSections[ slug ];
},
isBannerDismissed( state ) {
return state.bannerDismissed;
},
};
export const AI_STORE_NAME = 'jetpack/content-guidelines-ai';
const aiStore = createReduxStore( AI_STORE_NAME, {
reducer,
actions,
selectors,
} );
register( aiStore );
@@ -0,0 +1,267 @@
// Shared color tokens for AI suggestion UI.
// Jetpack ThemeProvider doesn't wrap this page,
// so we define fallback values here.
$cg-green: #069e08;
$cg-green-light: rgba(0, 135, 0, 0.08);
$cg-diff-added: #d4edda;
$cg-diff-removed: #f8d7da;
.jetpack-content-guidelines-ai__header-container {
display: inline-flex;
}
.jetpack-content-guidelines-ai__suggest-all-button .jetpack-logo {
width: 18px;
height: 18px;
}
// Empty state banner.
.jetpack-content-guidelines-ai__banner {
position: relative;
background: #003010;
border-radius: 8px;
padding: 32px;
overflow: hidden;
min-height: 220px;
display: flex;
align-items: flex-start;
width: min(680px, 100%);
box-sizing: border-box;
margin: 0 auto 24px;
}
.jetpack-content-guidelines-ai__banner-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
max-width: 520px;
h2 {
color: #daffdc;
font-size: 32px;
line-height: 40px;
font-weight: 500;
margin: 0 0 12px;
}
p {
color: #daffdc;
font-size: 13px;
line-height: 20px;
margin: 0 0 64px;
}
}
.jetpack-content-guidelines-ai__banner-actions {
display: flex;
gap: 8px;
}
.jetpack-content-guidelines-ai__banner-cta {
background-color: #48ff50 !important;
color: #003010 !important;
border-color: #48ff50 !important;
&:hover {
background-color: #3de046 !important;
border-color: #3de046 !important;
}
}
.jetpack-content-guidelines-ai__banner-close-text {
color: #48ff50 !important;
&:hover {
color: #3de046 !important;
}
}
.jetpack-content-guidelines-ai__banner-close {
position: absolute;
inset-block-start: 8px;
inset-inline-end: 8px;
z-index: 1;
color: #fff !important;
&:hover {
color: #daffdc !important;
}
}
.jetpack-content-guidelines-ai__banner-orb {
position: absolute;
border-radius: 50%;
pointer-events: none;
width: 180px;
height: 180px;
background: linear-gradient(to right, #04c, #48ff50);
// Shared by both orbs so the top glow and the bottom sphere line up
// on the same vertical axis.
inset-inline-end: 56px;
&--top {
inset-block-start: -96px;
filter: blur(25px);
}
&--bottom {
inset-block-end: -96px;
}
}
// Badge in accordion headers.
// Hide badge when the accordion is expanded — the Accept/Dismiss
// buttons inside already communicate the suggestion state. The badge is
// injected inside the CollapsibleCard trigger, which carries aria-expanded.
[aria-expanded="true"] .jetpack-content-guidelines-ai__badge-container {
display: none;
}
// Jetpack CSS custom properties are normally set by
// ThemeProvider, which doesn't wrap this page. Define
// the tokens our Badge component needs as fallbacks.
.jetpack-content-guidelines-ai__badge-container {
--jp-green-5: #d0e6b8;
--jp-green-50: #008710;
display: inline-flex;
align-items: center;
margin-inline-start: auto;
}
.jetpack-content-guidelines-ai__badge--loading .components-spinner {
margin: 0;
width: 16px;
height: 16px;
}
// Suggestion preview inside accordion.
// Match the VStack spacing={4} (16px gap) used by the Gutenberg form.
.jetpack-content-guidelines-ai__suggestion {
display: flex;
flex-direction: column;
gap: 16px;
}
// Word-level diff display.
// Styled to match the Gutenberg textarea — green border indicates
// a suggestion is active. Clicking anywhere accepts the suggestion.
.jetpack-content-guidelines-ai__diff {
padding: 9px 11px;
border: 1px solid var(--jp-green-40, $cg-green);
border-radius: 2px;
background: #fff;
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
overflow-wrap: break-word;
overflow-y: auto;
resize: vertical;
cursor: text;
box-sizing: border-box;
}
.jetpack-content-guidelines-ai__diff-added {
background: $cg-diff-added;
text-decoration: none;
}
.jetpack-content-guidelines-ai__diff-removed {
background: $cg-diff-removed;
text-decoration: line-through;
}
.jetpack-content-guidelines-ai__suggestion-actions {
display: flex;
gap: 8px;
}
// Green shimmer overlay on the textarea during AI generation.
// We make the textarea wrapper positioned so the ::after overlay works.
.is-jetpack-loading .components-textarea-control {
position: relative;
&::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 4px;
background: linear-gradient(90deg, transparent 0%, $cg-green-light 50%, transparent 100%);
background-size: 200% 100%;
animation: jetpack-cg-shimmer 1.5s infinite;
z-index: 1;
}
}
.is-jetpack-loading textarea {
border-color: var(--jp-green-40, $cg-green) !important;
pointer-events: none;
}
@keyframes jetpack-cg-shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
// Hide Gutenberg's DataForm and Save/Clear when a suggestion is active.
// Also hide the per-section generate button since Accept/Dismiss replaces it.
.has-jetpack-suggestion:not(.block-guideline-modal) > :first-child > :not(.jetpack-content-guidelines-ai__actions-container) {
display: none;
}
// Injected containers should not affect layout when their content is empty.
// React root inside means :empty won't work,
// so we use display:contents when not active.
.jetpack-content-guidelines-ai__actions-container,
.jetpack-content-guidelines-ai__section-button-container {
display: contents;
}
// Per-section generate button.
.jetpack-content-guidelines-ai__section-generate-button {
display: inline-flex;
}
// Upgrade notice.
.jetpack-content-guidelines-ai__upgrade-notice-container {
display: contents;
}
.jetpack-content-guidelines-ai__upgrade-notice {
width: min(680px, 100%);
margin: 0 auto 16px;
box-sizing: border-box;
}
// Block guideline modal — hide only the textarea input
// when suggestion is active, keeping the label visible.
.block-guideline-modal.has-jetpack-suggestion .components-textarea-control__input {
display: none;
}
// Block modal — shimmer only on the textarea,
// not the label wrapper. Suppress the ::after overlay
// and apply shimmer as a background on the textarea.
.block-guideline-modal.is-jetpack-loading .components-textarea-control::after {
content: none;
}
.block-guideline-modal.is-jetpack-loading .components-textarea-control__input {
background: linear-gradient(90deg, transparent 0%, $cg-green-light 50%, transparent 100%);
background-size: 200% 100%;
animation: jetpack-cg-shimmer 1.5s infinite;
}
// Block modal injected containers — collapse when empty.
.jetpack-content-guidelines-ai__block-actions-container,
.jetpack-content-guidelines-ai__block-suggestion-buttons-container {
display: contents;
}