+ ) ) }
+
+ >
+ ) }
+
+ );
+};
+
+const SWATCH_PREVIEW_COUNT = 5;
+
+const PaletteSummary = ( { palette, onEdit } ) => (
+
+
+ { palette
+ .slice( 0, SWATCH_PREVIEW_COUNT )
+ .map( ( entry, index ) => (
+
+ ) ) }
+
+
+
+);
+
+const ColorTab = ( {
+ colorSettings,
+ onChangeColorSettings,
+ palette,
+ onChangePalette,
+} ) => {
+ const [ isPaletteOpen, setIsPaletteOpen ] = useState( false );
+ const paletteRef = useRef( null );
+ const prevPaletteLengthRef = useRef( palette.length );
+
+ // When the palette transitions from empty (force-open) to having entries,
+ // the force-open condition stops applying. Without this, adding the very
+ // first color via the empty-state button would collapse the accordion and
+ // hide the row that was just added.
+ useEffect( () => {
+ if ( prevPaletteLengthRef.current === 0 && palette.length > 0 ) {
+ setIsPaletteOpen( true );
+ }
+ prevPaletteLengthRef.current = palette.length;
+ }, [ palette.length ] );
+
+ const focusPalette = () => {
+ setIsPaletteOpen( true );
+ // Scroll on the next frame so the accordion has expanded (if it was
+ // closed) before we measure its target position. Works on subsequent
+ // clicks too, because scrollIntoView fires unconditionally.
+ window.requestAnimationFrame( () => {
+ paletteRef.current?.scrollIntoView( {
+ behavior: 'smooth',
+ block: 'start',
+ } );
+ } );
+ };
+
+ return (
+ <>
+ { palette.length > 0 && (
+
+ ) }
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+const pickColorSettings = ( themeColor ) => {
+ const out = { ...COLOR_SETTINGS_DEFAULTS };
+ for ( const key of COLOR_SETTINGS_KEYS ) {
+ if ( themeColor && key in themeColor ) {
+ out[ key ] = themeColor[ key ];
+ }
+ }
+ return out;
+};
+
+// Human-readable label for each top-level slice of `settings` / `styles`
+// that the user may have customized in the Site Editor. Keys are taken
+// from the canonical theme.json schema; anything not in this map is
+// surfaced under "other" as a catch-all so newly-introduced WP keys don't
+// silently vanish from the warning.
+const USER_CUSTOMIZATION_SECTION_LABELS = {
+ color: __( 'color', 'create-block-theme' ),
+ typography: __( 'typography', 'create-block-theme' ),
+ spacing: __( 'spacing', 'create-block-theme' ),
+ layout: __( 'layout', 'create-block-theme' ),
+ dimensions: __( 'dimensions', 'create-block-theme' ),
+ border: __( 'borders', 'create-block-theme' ),
+ shadow: __( 'shadows', 'create-block-theme' ),
+ background: __( 'background', 'create-block-theme' ),
+ elements: __( 'elements', 'create-block-theme' ),
+ blocks: __( 'block styles', 'create-block-theme' ),
+ filter: __( 'filters', 'create-block-theme' ),
+ css: __( 'additional CSS', 'create-block-theme' ),
+ custom: __( 'custom', 'create-block-theme' ),
+};
+
+const isNonEmpty = ( value ) => {
+ if ( value === null || value === undefined ) {
+ return false;
+ }
+ if ( Array.isArray( value ) ) {
+ return value.length > 0;
+ }
+ if ( typeof value === 'object' ) {
+ return Object.keys( value ).length > 0;
+ }
+ return true;
+};
+
+// Crawl the saved user Global Styles record + any in-editor edits and
+// return the de-duplicated, human-readable list of top-level slices that
+// have user-level customizations diverging from theme.json.
+const getCustomizedSections = ( userGlobalStyles, edits ) => {
+ const sections = new Set();
+
+ const visit = ( record ) => {
+ if ( ! record ) {
+ return;
+ }
+ for ( const top of [ 'settings', 'styles' ] ) {
+ const slice = record[ top ];
+ if ( ! slice || typeof slice !== 'object' ) {
+ continue;
+ }
+ for ( const [ key, value ] of Object.entries( slice ) ) {
+ if ( isNonEmpty( value ) ) {
+ sections.add( key );
+ }
+ }
+ }
+ };
+
+ visit( userGlobalStyles );
+ visit( edits );
+
+ return Array.from( sections ).map(
+ ( key ) =>
+ USER_CUSTOMIZATION_SECTION_LABELS[ key ] ||
+ __( 'other', 'create-block-theme' )
+ );
+};
+
+// Returns the set of color-settings keys whose current value differs from
+// the last-saved snapshot. Used to drive both the Update-button counter
+// and the minimal-patch payload sent to the server.
+const getDirtyColorKeys = ( current, snapshot ) => {
+ const keys = [];
+ for ( const key of COLOR_SETTINGS_KEYS ) {
+ if ( current[ key ] !== snapshot[ key ] ) {
+ keys.push( key );
+ }
+ }
+ return keys;
+};
+
+export const EditThemeSettingsModal = ( { onRequestClose } ) => {
+ const themeData = useSelect(
+ ( select ) => select( 'core' ).getCurrentTheme(),
+ []
+ );
+
+ const customizedSections = useSelect( ( select ) => {
+ const core = select( 'core' );
+ const getId = core.__experimentalGetCurrentGlobalStylesId;
+ if ( typeof getId !== 'function' ) {
+ return [];
+ }
+ const id = getId();
+ if ( ! id ) {
+ return [];
+ }
+ // Saved user-origin record (database).
+ const record = core.getEntityRecord( 'root', 'globalStyles', id );
+ // In-editor edits not yet persisted to the database.
+ const edits = core.getEntityRecordEdits?.( 'root', 'globalStyles', id );
+ return getCustomizedSections( record, edits );
+ }, [] );
+ const hasUserCustomizations = customizedSections.length > 0;
+
+ const { invalidateResolution } = useDispatch( 'core' );
+ const { createSuccessNotice, createErrorNotice } =
+ useDispatch( noticesStore );
+
+ const themeColor = themeData?.theme_json?.settings?.color;
+
+ // `snapshot` is the server-canonical state — palette entries here have
+ // NO `__cbtRowId` since the server never sees that key. Working state
+ // (`palette`) is augmented with row IDs for stable React keys.
+ const initialState = useMemo(
+ () => ( {
+ colorSettings: pickColorSettings( themeColor ),
+ palette: Array.isArray( themeColor?.palette )
+ ? themeColor.palette
+ : [],
+ } ),
+ [ themeColor ]
+ );
+
+ const [ colorSettings, setColorSettings ] = useState(
+ initialState.colorSettings
+ );
+ const [ palette, setPalette ] = useState( () =>
+ augmentPaletteWithIds( initialState.palette )
+ );
+ const [ snapshot, setSnapshot ] = useState( initialState );
+ const [ isSaving, setIsSaving ] = useState( false );
+
+ const dirtyColorKeys = useMemo(
+ () => getDirtyColorKeys( colorSettings, snapshot.colorSettings ),
+ [ colorSettings, snapshot.colorSettings ]
+ );
+ const strippedPalette = useMemo(
+ () => stripPaletteIds( palette ),
+ [ palette ]
+ );
+ const paletteDirty = useMemo(
+ () => ! palettesEqual( strippedPalette, snapshot.palette ),
+ [ strippedPalette, snapshot.palette ]
+ );
+ const changeCount = dirtyColorKeys.length + ( paletteDirty ? 1 : 0 );
+ const isDirty = changeCount > 0;
+
+ const hasEmptySlug = palette.some(
+ ( entry ) => ! entry.slug || ! entry.slug.trim()
+ );
+
+ // Reseed local state from refreshed server data — but only if the user
+ // hasn't started editing in this session. Otherwise a mid-flight cache
+ // invalidation (e.g. another resolver triggers it) would silently wipe
+ // pending edits.
+ useEffect( () => {
+ if ( isDirty ) {
+ return;
+ }
+ setColorSettings( initialState.colorSettings );
+ setPalette( augmentPaletteWithIds( initialState.palette ) );
+ setSnapshot( initialState );
+ // `isDirty` is intentionally excluded from deps: we only want to
+ // reseed when the server-side data changes, not when the user's
+ // edits transition the dirty flag.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ initialState ] );
+
+ const handleUpdateClick = async () => {
+ // Build a minimal patch: only the keys the user touched in this
+ // session. Avoids overwriting fields another tab/CLI may have
+ // edited concurrently (server-side merge is RFC 7396 last-writes-
+ // wins on lists, so sending an unchanged palette would still
+ // clobber concurrent palette edits).
+ const colorPayload = {};
+ for ( const key of dirtyColorKeys ) {
+ colorPayload[ key ] = colorSettings[ key ];
+ }
+ if ( paletteDirty ) {
+ colorPayload.palette = strippedPalette;
+ }
+ if ( Object.keys( colorPayload ).length === 0 ) {
+ return;
+ }
+
+ setIsSaving( true );
+ try {
+ // The endpoint returns `{ status, theme_json: }` on
+ // success. Reseed snapshot from the merged theme.json directly
+ // instead of relying on a refetch: `getCurrentTheme` is
+ // entity-record-backed and `invalidateResolution` doesn't
+ // reliably re-fetch it before the user sees the dirty count.
+ const response = await postUpdateThemeSettings( {
+ settings: { color: colorPayload },
+ } );
+ const savedColor = response?.theme_json?.settings?.color || {};
+ const nextColorSettings = pickColorSettings( savedColor );
+ const nextPaletteRaw = Array.isArray( savedColor.palette )
+ ? savedColor.palette
+ : [];
+ setColorSettings( nextColorSettings );
+ setPalette( augmentPaletteWithIds( nextPaletteRaw ) );
+ setSnapshot( {
+ colorSettings: nextColorSettings,
+ palette: nextPaletteRaw,
+ } );
+ createSuccessNotice(
+ __( 'Theme settings saved.', 'create-block-theme' ),
+ { type: 'snackbar' }
+ );
+ // Also invalidate the entity-record cache so the rest of the UI
+ // (e.g. View theme.json, future modal opens) sees fresh data.
+ invalidateResolution( 'getCurrentTheme' );
+ } catch ( error ) {
+ createErrorNotice(
+ error?.message ||
+ __(
+ 'An error occurred while saving theme settings.',
+ 'create-block-theme'
+ ),
+ { type: 'snackbar' }
+ );
+ } finally {
+ setIsSaving( false );
+ }
+ };
+
+ const tabs = [
+ { name: 'color', title: __( 'Color', 'create-block-theme' ) },
+ ];
+
+ const renderTab = ( tab ) => {
+ switch ( tab.name ) {
+ case 'color':
+ return (
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ const updateLabel = isDirty
+ ? sprintf(
+ /* translators: %d: number of pending changes */
+ _n(
+ 'Update (%d change)',
+ 'Update (%d changes)',
+ changeCount,
+ 'create-block-theme'
+ ),
+ changeCount
+ )
+ : __( 'Update', 'create-block-theme' );
+
+ // Use Intl.ListFormat so the separator (and final "and") match the
+ // user's locale instead of being hard-coded English punctuation.
+ // Falls back to a plain ", " join in environments without it.
+ const sectionList =
+ typeof Intl !== 'undefined' && Intl.ListFormat
+ ? new Intl.ListFormat( undefined, {
+ style: 'long',
+ type: 'conjunction',
+ } ).format( customizedSections )
+ : customizedSections.join( ', ' );
+
+ return (
+
+
+
+ { __(
+ 'Edit the settings of the current theme.',
+ 'create-block-theme'
+ ) }
+
+ { hasUserCustomizations && (
+
+
+ { createInterpolateElement(
+ sprintf(
+ /* translators: %s: comma-separated list of customized sections, wrapped in (e.g. "color, typography") */
+ __(
+ 'You have changes in the Site Editor that haven’t been written to theme.json: %s.',
+ 'create-block-theme'
+ ),
+ sectionList
+ ),
+ { list: }
+ ) }
+
+
+ { createInterpolateElement(
+ __(
+ 'Click Save Changes to Theme first — otherwise the edits you make here may be hidden by those overrides.',
+ 'create-block-theme'
+ ),
+ { strong: }
+ ) }
+
+
+ ) }
+
+ { renderTab }
+
+
+
+
+
+
+ );
+};
diff --git a/src/editor-sidebar/json-editor-modal.js b/src/editor-sidebar/json-editor-modal.js
index 1f7e3d90..aafdc0a4 100644
--- a/src/editor-sidebar/json-editor-modal.js
+++ b/src/editor-sidebar/json-editor-modal.js
@@ -10,22 +10,47 @@ import { json } from '@codemirror/lang-json';
import { __, sprintf } from '@wordpress/i18n';
import { useState, useEffect } from '@wordpress/element';
import { Modal } from '@wordpress/components';
-import { useSelect } from '@wordpress/data';
+import { useDispatch } from '@wordpress/data';
+import apiFetch from '@wordpress/api-fetch';
const ThemeJsonEditorModal = ( { onRequestClose } ) => {
const [ themeData, setThemeData ] = useState( '' );
- const themeJsonData = useSelect(
- ( select ) => select( 'core' ).getCurrentTheme(),
- []
- );
+ const [ themeName, setThemeName ] = useState( '' );
+ const { invalidateResolution } = useDispatch( 'core' );
+ // Fetch directly via the REST API on every mount so the modal always
+ // shows the on-disk theme.json — bypassing the @wordpress/core-data
+ // cache that would otherwise serve stale content when the user opens
+ // View theme.json straight after closing the Edit Theme Settings modal.
+ // Also invalidate the cached resolution so other subscribers refresh.
useEffect( () => {
- if ( themeJsonData ) {
- setThemeData(
- JSON.stringify( themeJsonData?.theme_json, null, 2 )
- );
- }
- }, [ themeJsonData ] );
+ let cancelled = false;
+ invalidateResolution( 'getCurrentTheme' );
+ apiFetch( { path: '/wp/v2/themes?status=active' } )
+ .then( ( themes ) => {
+ if ( cancelled ) {
+ return;
+ }
+ const active = Array.isArray( themes ) ? themes[ 0 ] : null;
+ if ( ! active ) {
+ return;
+ }
+ setThemeName( active?.name?.raw ?? '' );
+ setThemeData( JSON.stringify( active?.theme_json, null, 2 ) );
+ } )
+ .catch( ( err ) => {
+ // Leave the modal showing whatever was last rendered, but
+ // surface the failure to the console so it isn't invisible.
+ // eslint-disable-next-line no-console
+ console.error(
+ 'View theme.json: failed to fetch active theme',
+ err
+ );
+ } );
+ return () => {
+ cancelled = true;
+ };
+ }, [ invalidateResolution ] );
const handleSave = () => {};
@@ -35,7 +60,7 @@ const ThemeJsonEditorModal = ( { onRequestClose } ) => {
title={ sprintf(
// translators: %s: theme name.
__( 'theme.json for %s', 'create-block-theme' ),
- themeJsonData?.name?.raw ?? ''
+ themeName
) }
onRequestClose={ onRequestClose }
className="create-block-theme__theme-json-modal"
diff --git a/src/plugin-sidebar.js b/src/plugin-sidebar.js
index 2d283361..cee37ac2 100644
--- a/src/plugin-sidebar.js
+++ b/src/plugin-sidebar.js
@@ -38,7 +38,7 @@ import {
tool,
copy,
download,
- edit,
+ pencil,
code,
chevronLeft,
chevronRight,
@@ -58,6 +58,7 @@ import GlobalStylesJsonEditorModal from './editor-sidebar/global-styles-json-edi
import { SaveThemePanel } from './editor-sidebar/save-panel';
import { CreateVariationPanel } from './editor-sidebar/create-variation-panel';
import { ThemeMetadataEditorModal } from './editor-sidebar/metadata-editor-modal';
+import { EditThemeSettingsModal } from './editor-sidebar/edit-theme-settings-modal';
import ScreenHeader from './editor-sidebar/screen-header';
import { downloadExportedTheme } from './resolvers';
import downloadFile from './utils/download-file';
@@ -90,6 +91,8 @@ const CreateBlockThemePlugin = () => {
const [ isMetadataEditorOpen, setIsMetadataEditorOpen ] = useState( false );
+ const [ isThemeSettingsOpen, setIsThemeSettingsOpen ] = useState( false );
+
const [ cloneCreateType, setCloneCreateType ] = useState( '' );
const { createErrorNotice } = useDispatch( noticesStore );
@@ -157,7 +160,18 @@ const CreateBlockThemePlugin = () => {
) }
+ setIsThemeSettingsOpen( true )
+ }
+ >
+ { __(
+ 'Edit Theme Settings',
+ 'create-block-theme'
+ ) }
+
+
setIsMetadataEditorOpen( true )
}
@@ -378,6 +392,12 @@ const CreateBlockThemePlugin = () => {
onRequestClose={ () => setIsMetadataEditorOpen( false ) }
/>
) }
+
+ { isThemeSettingsOpen && (
+ setIsThemeSettingsOpen( false ) }
+ />
+ ) }
>
);
};
diff --git a/src/plugin-styles.scss b/src/plugin-styles.scss
index f80c7463..3a759507 100644
--- a/src/plugin-styles.scss
+++ b/src/plugin-styles.scss
@@ -1,4 +1,5 @@
@import "~@wordpress/base-styles/colors";
+@import "~@wordpress/base-styles/breakpoints";
$modal-footer-height: 70px;
@@ -11,8 +12,8 @@ $modal-footer-height: 70px;
}
.create-block-theme__metadata-editor-modal__footer {
- border-top: 1px solid #ddd;
- background-color: #fff;
+ border-top: 1px solid $gray-300;
+ background-color: $white;
position: absolute;
bottom: 0;
margin: 0 -32px;
@@ -27,3 +28,95 @@ $modal-footer-height: 70px;
object-fit: cover;
}
+.create-block-theme__edit-theme-settings-modal {
+ padding-bottom: $modal-footer-height;
+
+ // Drop the top border on the first PanelBody when it sits below a
+ // non-panel sibling (e.g. our PaletteSummary card) so the boundary
+ // doesn't double-up with the summary's bottom edge.
+ .components-tab-panel__tab-content > :not(.components-panel__body) + .components-panel__body {
+ border-top: 0;
+ }
+}
+
+.create-block-theme__edit-theme-settings-modal__footer {
+ border-top: 1px solid $gray-300;
+ background-color: $white;
+ position: absolute;
+ bottom: 0;
+ margin: 0 -32px;
+ padding: 16px 32px;
+ height: $modal-footer-height;
+}
+
+.cbt-palette-summary {
+ padding: 8px 0;
+ margin-bottom: 8px;
+
+ &__swatches > * + * {
+ // Overlap each subsequent swatch onto its predecessor.
+ margin-left: -8px;
+ }
+
+ &__edit {
+ text-decoration: none;
+ }
+}
+
+.cbt-color-settings-columns {
+ // Two side-by-side groups (Default presets / Custom presets) on wider
+ // modals; collapse to a single column at narrow widths.
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 32px;
+ // Top-align so the two group headers (and their first toggle) line up
+ // even when the columns contain different numbers of toggles.
+ align-items: start;
+
+ @media (min-width: $break-small) {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.cbt-palette-swatch-button {
+ min-width: 24px;
+ padding: 0;
+}
+
+.cbt-palette-section-header {
+ margin-bottom: 8px;
+
+ // Nudge the right-edge action (e.g. "+ Add a color") -8px so it aligns
+ // with the PanelBody chevron in the accordion header directly above.
+ > :last-child {
+ margin-right: -8px;
+ }
+}
+
+.cbt-palette-column-headers {
+ // Match the horizontal padding @wordpress/components Item applies to row
+ // content so column headers line up with the inputs below.
+ padding: 0 12px;
+
+ &__spacer {
+ display: inline-block;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ }
+
+ &__label {
+ display: block;
+ // The wrapper above already pads horizontally to match the row's
+ // Item padding, so the label sits at the same x-offset as the
+ // TextControl input directly below it.
+ padding-left: 0;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 1.4;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: $gray-700;
+ }
+}
+
diff --git a/src/resolvers.js b/src/resolvers.js
index d0219fc8..2cc4ac0c 100644
--- a/src/resolvers.js
+++ b/src/resolvers.js
@@ -85,6 +85,17 @@ export async function postUpdateThemeMetadata( theme ) {
} );
}
+export async function postUpdateThemeSettings( payload ) {
+ return apiFetch( {
+ path: '/create-block-theme/v1/theme-settings',
+ method: 'POST',
+ data: payload,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ } );
+}
+
export async function downloadExportedTheme() {
return apiFetch( {
path: '/create-block-theme/v1/export',