PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-global-styles.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 3 weeks ago class-avcf-abilities-core.php 3 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 3 weeks ago class-avcf-abilities-media.php 3 weeks ago class-avcf-abilities-metadata.php 3 weeks ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 3 weeks ago class-avcf-abilities-themes.php 3 weeks ago class-avcf-abilities-users.php 3 weeks ago
class-avcf-abilities-global-styles.php
306 lines
1 <?php
2 /**
3 * Global styles (theme.json user overrides) MCP abilities.
4 *
5 * Global styles are the single biggest lever for making a block-theme site
6 * look bespoke rather than default: the colour palette, typography, spacing,
7 * and per-element/per-block style rules. They are stored as a user-layer
8 * theme.json document in the wp_global_styles custom post type — invisible to
9 * the generic content and settings tools.
10 *
11 * get-global-styles reports both the PRESETS the active theme defines (the
12 * palette slugs, font families, font sizes and spacing sizes the AI may
13 * reference) and the CURRENT user overrides on top of them. update-global-styles
14 * deep-merges a partial theme.json-shaped payload into the existing overrides,
15 * sanitises it through WP_Theme_JSON (the same validation core's Site Editor
16 * uses), and saves it — so a caller can set brand colours or fonts without
17 * having to send the whole document.
18 *
19 * Colour/font/spacing values may be given as preset references
20 * ("var:preset|color|accent-1") or literals ("#0d1b2a", "1.25rem"); preset
21 * references are the portable form and are normalised to CSS custom properties
22 * on save.
23 *
24 * Exposed abilities:
25 * atarim/get-global-styles Theme presets + current user overrides.
26 * atarim/update-global-styles Deep-merge a partial theme.json into user overrides.
27 *
28 * @package atarim-visual-collaboration
29 */
30
31 if ( ! defined('ABSPATH') ) {
32 exit;
33 }
34
35 class AVCF_Abilities_Global_Styles extends AVCF_Abilities_Base {
36
37 public function register() {
38 $this->register_get();
39 $this->register_update();
40 }
41
42 private function register_get() {
43 wp_register_ability( 'atarim/get-global-styles', [
44 'label' => 'Get Global Styles',
45 'description' => 'Returns the site\'s global design tokens. presets are what the active theme defines and what update-global-styles can reference by slug: palette (colour slug + value + name), gradients, font_families (slug + name), font_sizes (slug + size) and spacing_sizes. user_styles and user_settings are the current theme.json overrides layered on top (empty objects when the theme defaults are untouched). Read this before update-global-styles so you use real preset slugs (e.g. reference the accent colour as "var:preset|color|accent-1") and know what is already set.',
46 'category' => 'atarim',
47 'input_schema' => [
48 'type' => 'object',
49 'properties' => [],
50 'additionalProperties' => false,
51 ],
52 'output_schema' => [
53 'type' => 'object',
54 'properties' => [
55 'success' => [ 'type' => 'boolean' ],
56 'is_block_theme'=> [ 'type' => 'boolean' ],
57 'presets' => [ 'type' => 'object' ],
58 'user_styles' => [ 'type' => 'object' ],
59 'user_settings' => [ 'type' => 'object' ],
60 'message' => [ 'type' => 'string' ],
61 ],
62 'required' => [ 'success', 'message' ],
63 ],
64 'execute_callback' => function( $input = [] ) {
65 if ( ! class_exists( 'WP_Theme_JSON_Resolver' ) ) {
66 return [ 'success' => false, 'message' => 'This WordPress version does not support global styles (theme.json).' ];
67 }
68
69 $settings = WP_Theme_JSON_Resolver::get_theme_data()->get_settings();
70 $presets = [
71 'palette' => $this->avcf_map_preset( $settings, [ 'color', 'palette' ], [ 'slug', 'color', 'name' ] ),
72 'gradients' => $this->avcf_map_preset( $settings, [ 'color', 'gradients' ], [ 'slug', 'gradient', 'name' ] ),
73 'font_families' => $this->avcf_map_preset( $settings, [ 'typography', 'fontFamilies' ], [ 'slug', 'name' ] ),
74 'font_sizes' => $this->avcf_map_preset( $settings, [ 'typography', 'fontSizes' ], [ 'slug', 'size', 'name' ] ),
75 'spacing_sizes' => $this->avcf_map_preset( $settings, [ 'spacing', 'spacingSizes' ], [ 'slug', 'size', 'name' ] ),
76 ];
77
78 $user = $this->avcf_read_user_global_styles();
79
80 return [
81 'success' => true,
82 'is_block_theme'=> function_exists( 'wp_is_block_theme' ) && wp_is_block_theme(),
83 'presets' => $presets,
84 'user_styles' => isset( $user['styles'] ) && is_array( $user['styles'] ) ? $user['styles'] : (object) [],
85 'user_settings' => isset( $user['settings'] ) && is_array( $user['settings'] ) ? $user['settings'] : (object) [],
86 'message' => sprintf( '%d palette colour(s), %d font family(ies) available.', count( $presets['palette'] ), count( $presets['font_families'] ) ),
87 ];
88 },
89 'permission_callback' => function() {
90 return current_user_can( 'edit_theme_options' );
91 },
92 'meta' => [
93 'mcp' => [ 'public' => true, 'type' => 'tool' ],
94 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
95 ],
96 ] );
97 }
98
99 private function register_update() {
100 wp_register_ability( 'atarim/update-global-styles', [
101 'label' => 'Update Global Styles',
102 'description' => 'Deep-merges a partial theme.json into the site\'s user global styles — the site-wide colours, typography, and spacing. Pass styles (theme.json "styles" shape: e.g. {"color":{"background":"var:preset|color|base","text":"var:preset|color|contrast"},"elements":{"button":{"color":{"background":"var:preset|color|accent-1"}}},"typography":{"fontFamily":"var:preset|font-family|manrope"}}) and/or settings (theme.json "settings" shape, e.g. custom palette definitions). Only the keys you pass are changed; everything else is preserved (deep merge, not replace). Values may be preset references ("var:preset|color|<slug>" — get valid slugs from get-global-styles) or literals ("#0d1b2a"). Pass null as a value to REMOVE that key from the overrides (e.g. {"elements":{"button":{"color":{"background":null}}}} drops a previously-set button colour). Pass reset:true to clear ALL user overrides and fall back to the theme defaults (the global-styles equivalent of revert-template) — reset ignores styles/settings. The payload is sanitised through WordPress\'s theme.json validator; unknown or unsafe keys are dropped. Affects every page site-wide.',
103 'category' => 'atarim',
104 'input_schema' => [
105 'type' => 'object',
106 'properties' => [
107 'styles' => [ 'type' => 'object', 'description' => 'Partial theme.json "styles" object to merge. Use null values to remove keys.' ],
108 'settings' => [ 'type' => 'object', 'description' => 'Partial theme.json "settings" object to merge. Use null values to remove keys.' ],
109 'reset' => [ 'type' => 'boolean', 'description' => 'When true, clears ALL user overrides (styles and settings) and restores the theme defaults. Ignores styles/settings.' ],
110 ],
111 'additionalProperties' => false,
112 ],
113 'output_schema' => [
114 'type' => 'object',
115 'properties' => [
116 'success' => [ 'type' => 'boolean' ],
117 'user_styles' => [ 'type' => 'object' ],
118 'user_settings' => [ 'type' => 'object' ],
119 'message' => [ 'type' => 'string' ],
120 ],
121 'required' => [ 'success', 'message' ],
122 ],
123 'execute_callback' => function( $input = [] ) {
124 if ( ! class_exists( 'WP_Theme_JSON_Resolver' ) || ! class_exists( 'WP_Theme_JSON' ) ) {
125 return [ 'success' => false, 'message' => 'This WordPress version does not support global styles (theme.json).' ];
126 }
127
128 $reset = ! empty( $input['reset'] );
129 $has_styles = isset( $input['styles'] ) && is_array( $input['styles'] );
130 $has_settings = isset( $input['settings'] ) && is_array( $input['settings'] );
131 if ( ! $reset && ! $has_styles && ! $has_settings ) {
132 return [ 'success' => false, 'message' => 'Provide styles and/or settings to merge, or reset:true.' ];
133 }
134
135 $gid = WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
136 if ( ! $gid ) {
137 return [ 'success' => false, 'message' => 'Could not resolve the user global styles record.' ];
138 }
139
140 $data = $this->avcf_read_user_global_styles();
141 $version = isset( $data['version'] ) ? (int) $data['version'] : 2;
142
143 if ( $reset ) {
144 $empty = [ 'version' => $version, 'isGlobalStylesUserThemeJSON' => true ];
145 $saved = wp_update_post( [ 'ID' => (int) $gid, 'post_content' => wp_json_encode( $empty ) ], true );
146 if ( is_wp_error( $saved ) ) {
147 return [ 'success' => false, 'message' => 'Reset failed: ' . $saved->get_error_message() ];
148 }
149 WP_Theme_JSON_Resolver::clean_cached_data();
150 return [
151 'success' => true,
152 'user_styles' => (object) [],
153 'user_settings' => (object) [],
154 'message' => 'Global styles reset to theme defaults.',
155 ];
156 }
157
158 if ( $has_styles ) {
159 $data['styles'] = $this->avcf_deep_merge( isset( $data['styles'] ) ? $data['styles'] : [], $input['styles'] );
160 }
161 if ( $has_settings ) {
162 $data['settings'] = $this->avcf_deep_merge( isset( $data['settings'] ) ? $data['settings'] : [], $input['settings'] );
163 }
164
165 $config = [ 'version' => $version ];
166 if ( ! empty( $data['styles'] ) ) {
167 $config['styles'] = $data['styles'];
168 }
169 if ( ! empty( $data['settings'] ) ) {
170 $config['settings'] = $data['settings'];
171 }
172
173 $sanitised = ( new WP_Theme_JSON( $config, 'custom' ) )->get_raw_data();
174 $sanitised['isGlobalStylesUserThemeJSON'] = true;
175 if ( empty( $sanitised['version'] ) ) {
176 $sanitised['version'] = $version;
177 }
178
179 $saved = wp_update_post( [ 'ID' => (int) $gid, 'post_content' => wp_json_encode( $sanitised ) ], true );
180 if ( is_wp_error( $saved ) ) {
181 return [ 'success' => false, 'message' => 'Save failed: ' . $saved->get_error_message() ];
182 }
183
184 WP_Theme_JSON_Resolver::clean_cached_data();
185
186 return [
187 'success' => true,
188 'user_styles' => isset( $sanitised['styles'] ) ? $sanitised['styles'] : (object) [],
189 'user_settings' => isset( $sanitised['settings'] ) ? $sanitised['settings'] : (object) [],
190 'message' => 'Global styles updated site-wide.',
191 ];
192 },
193 'permission_callback' => function() {
194 return current_user_can( 'edit_theme_options' );
195 },
196 'meta' => [
197 'mcp' => [ 'public' => true, 'type' => 'tool' ],
198 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ],
199 ],
200 ] );
201 }
202
203 /**
204 * Read and decode the user global styles theme.json document.
205 *
206 * @return array
207 */
208 private function avcf_read_user_global_styles() {
209 $gid = WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
210 if ( ! $gid ) {
211 return [];
212 }
213 $data = json_decode( (string) get_post( $gid )->post_content, true );
214 return is_array( $data ) ? $data : [];
215 }
216
217 /**
218 * Extract a preset group from a settings array into a lean list, keeping
219 * only the requested keys from each entry.
220 *
221 * @param array $settings
222 * @param array $path Path into the settings array, e.g. ['color','palette'].
223 * @param array $keys Keys to keep from each preset entry.
224 * @return array<int,array>
225 */
226 private function avcf_map_preset( $settings, $path, $keys ) {
227 $node = $settings;
228 foreach ( $path as $segment ) {
229 if ( ! is_array( $node ) || ! isset( $node[ $segment ] ) ) {
230 return [];
231 }
232 $node = $node[ $segment ];
233 }
234
235 // Presets can be grouped by origin (theme/default/custom) or be a flat list.
236 if ( isset( $node['theme'] ) || isset( $node['default'] ) || isset( $node['custom'] ) ) {
237 $flat = [];
238 foreach ( [ 'theme', 'custom', 'default' ] as $origin ) {
239 if ( ! empty( $node[ $origin ] ) && is_array( $node[ $origin ] ) ) {
240 $flat = array_merge( $flat, $node[ $origin ] );
241 }
242 }
243 $node = $flat;
244 }
245
246 $out = [];
247 foreach ( (array) $node as $entry ) {
248 if ( ! is_array( $entry ) ) {
249 continue;
250 }
251 $picked = [];
252 foreach ( $keys as $k ) {
253 if ( isset( $entry[ $k ] ) ) {
254 $picked[ $k ] = $entry[ $k ];
255 }
256 }
257 if ( ! empty( $picked ) ) {
258 $out[] = $picked;
259 }
260 }
261 return $out;
262 }
263
264 /**
265 * Recursively merge $overrides into $base. Associative arrays merge by key;
266 * scalars and lists overwrite. Used to layer a partial theme.json onto the
267 * existing user document without clobbering untouched branches.
268 *
269 * @param array $base
270 * @param array $overrides
271 * @return array
272 */
273 private function avcf_deep_merge( $base, $overrides ) {
274 foreach ( $overrides as $key => $value ) {
275 if ( $value === null ) {
276 unset( $base[ $key ] );
277 continue;
278 }
279 if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && $this->avcf_is_assoc( $value ) ) {
280 $merged = $this->avcf_deep_merge( $base[ $key ], $value );
281 if ( $merged === [] ) {
282 unset( $base[ $key ] );
283 } else {
284 $base[ $key ] = $merged;
285 }
286 continue;
287 }
288 $base[ $key ] = $value;
289 }
290 return $base;
291 }
292
293 /**
294 * Whether an array is associative (string keys) rather than a list.
295 *
296 * @param array $arr
297 * @return bool
298 */
299 private function avcf_is_assoc( $arr ) {
300 if ( $arr === [] ) {
301 return false;
302 }
303 return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
304 }
305 }
306