Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.34% covered (warning)
69.34%
303 / 437
31.58% covered (danger)
31.58%
6 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
GlobalStyles
69.34% covered (warning)
69.34%
303 / 437
31.58% covered (danger)
31.58%
6 / 19
496.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_abilities
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_get_global_styles
87.50% covered (warning)
87.50%
28 / 32
0.00% covered (danger)
0.00%
0 / 1
1.00
 register_update_global_styles
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
1 / 1
1
 register_get_active_global_styles
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 register_get_active_global_styles_id
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 execute_update_global_styles
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
10.10
 diff_requested_vs_stored
60.00% covered (warning)
60.00%
21 / 35
0.00% covered (danger)
0.00%
0 / 1
5.02
 detect_misplaced_application
37.50% covered (danger)
37.50%
6 / 16
0.00% covered (danger)
0.00%
0 / 1
14.79
 walk_leaves
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
12.04
 lookup_path
68.57% covered (warning)
68.57%
24 / 35
0.00% covered (danger)
0.00%
0 / 1
23.95
 values_match
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
10.86
 collect_known_preset_slugs
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
14
 flatten_origin_buckets
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
10.20
 detect_unresolved_preset_reference
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
38.90
 build_registration_snippet
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
30
 explain_missing_leaf
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 coerce_to_array
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 iter_per_block_and_global_settings
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
11.64
1<?php
2declare( strict_types=1 );
3
4namespace BLU\Abilities;
5
6/**
7 * Global Styles class
8 *
9 * Registers abilities for getting and updating WordPress global styles.
10 * Global styles are part of the Full Site Editing (FSE) system and contain
11 * theme.json configuration and user customizations.
12 */
13class GlobalStyles {
14
15    /**
16     * Constructor
17     */
18    public function __construct() {
19        $this->register_abilities();
20    }
21
22    /**
23     * Register all global styles abilities
24     *
25     * @return void
26     */
27    private function register_abilities(): void {
28        $this->register_get_global_styles();
29        $this->register_update_global_styles();
30        $this->register_get_active_global_styles();
31        $this->register_get_active_global_styles_id();
32    }
33
34    /**
35     * Register ability to get a specific global styles configuration
36     *
37     * @return void
38     */
39    private function register_get_global_styles(): void {
40        blu_register_ability(
41            'blu/get-global-styles',
42            array(
43                'label'               => 'Get Global Styles',
44                'description'         => 'Get a specific global styles configuration by ID. Returns theme.json settings and user customizations including colors, typography, and spacing. Only use this when you need to inspect the current styles — do NOT call this before blu/update-global-styles, which resolves the ID automatically.',
45                'category'            => 'blu-mcp',
46                'input_schema'        => array(
47                    'type'       => 'object',
48                    'properties' => array(
49                        'id' => array(
50                            'type'        => 'integer',
51                            'description' => 'Global styles ID',
52                        ),
53                    ),
54                    'required'   => array( 'id' ),
55                ),
56                'execute_callback'    => function ( $input ) {
57                    $id       = intval( $input['id'] );
58                    $request  = new \WP_REST_Request( 'GET', '/wp/v2/global-styles/' . $id );
59                    $response = rest_do_request( $request );
60                    return blu_standardize_rest_response( $response );
61                },
62                'permission_callback' => fn() => current_user_can( 'edit_theme_options' ),
63                'meta'                => array(
64                    'annotations' => array(
65                        'readonly'    => true,
66                        'destructive' => false,
67                        'idempotent'  => true,
68                    ),
69                ),
70            )
71        );
72    }
73
74    /**
75     * Register ability to update global styles
76     *
77     * @return void
78     */
79    private function register_update_global_styles(): void {
80        blu_register_ability(
81            'blu/update-global-styles',
82            array(
83                'label'               => 'Update Global Styles',
84                'description'         => 'Update WordPress global styles (colors, typography, spacing) using theme.json format. Resolves the global styles ID automatically — do NOT call get-global-styles first. Use for site-wide palette, font, or spacing changes — NOT for individual block colors. SETTINGS vs STYLES — these are NOT interchangeable: "settings" REGISTERS what is available (adds a font to the picker, declares a palette slot); writing only to "settings" does NOT change what visitors see. "styles" APPLIES values to the rendered page (sets the active font, sets the active background). To change the font users actually see you almost always need BOTH: register the family under settings.typography.fontFamilies AND apply it under styles.typography.fontFamily (site-wide) and/or styles.blocks.<block>.typography.fontFamily (per block). Per-block APPLICATION (the active font/color for a specific block) lives under styles.blocks.*; per-block REGISTRATION (settings.blocks.<block>.typography.fontFamilies, settings.blocks.<block>.appearanceTools, etc.) is a real WP feature and stays under settings.blocks.*. The common mistake is putting an application value (typography.fontFamily singular, color.background, color.text, etc.) under settings.blocks.<block> — that has no visual effect; move it to styles.blocks.<block>.<same-sub-path>. PRESET REFERENCES use the literal token "var:preset|font-family|<slug>" (note "font-family", not "font"); for colors use "var:preset|color|<slug>". The CSS-variable form var(--wp--preset--font-family--<slug>) also works and is equivalent. FONT CHANGE EXAMPLE — apply Fira Code site-wide and to common blocks: {"settings":{"typography":{"fontFamilies":[{"slug":"fira-code","name":"Fira Code","fontFamily":"\"Fira Code\", monospace"}]}},"styles":{"typography":{"fontFamily":"var:preset|font-family|fira-code"},"blocks":{"core/heading":{"typography":{"fontFamily":"var:preset|font-family|fira-code"}},"core/paragraph":{"typography":{"fontFamily":"var:preset|font-family|fira-code"}},"core/button":{"typography":{"fontFamily":"var:preset|font-family|fira-code"}}}}}. The response includes an "applied" list (paths that landed) and a "not_applied" list (paths sent but not in effect, each with a reason). If "not_applied" is non-empty the change did NOT fully succeed — fix the payload and retry; do NOT report success to the user. COLOR SLUGS: base=Background, base-midtone=Background midtone, contrast=Text, contrast-midtone=Text midtone, accent-2=Primary, accent-5=Secondary. Only include slugs you are changing — others are preserved. MIDTONE COLORS: When changing base, also update base-midtone (a subtle step toward contrast). When changing contrast, also update contrast-midtone (a subtle step toward base). Light theme example: base=#ffffff, base-midtone=#f4f4f4, contrast=#000000, contrast-midtone=#323232. Dark theme example: base=#181818, base-midtone=#1C1C1C, contrast=#FFFFFF, contrast-midtone=#DADADA. ACCENT COLORS: Generate ALL 6 shades via HSL lightness from the base color: accent-1(-24%), accent-2(base), accent-3(+18%), accent-4(+28%), accent-5(+56%), accent-6(+63%). Example for deep blue #0B3D5B: accent-1=#062533, accent-2=#0B3D5B, accent-3=#1A5A7A, accent-4=#2A7399, accent-5=#6BAAC9, accent-6=#8DC1D9. DARK/LIGHT MODE: Only change base + base-midtone + contrast + contrast-midtone, NEVER modify accents. "base" must be white/near-white for light or dark grey for dark themes. "contrast" must be the opposite. VAGUE PALETTE REQUESTS: When user says "change colors" without specifying which, ask what colors or mood they want first — do not apply immediately. PALETTE FORMAT: {"settings":{"color":{"palette":{"theme":[{"slug":"...","color":"#hex","name":"..."}]}}}}',
85                'category'            => 'blu-mcp',
86                'input_schema'        => array(
87                    'type'       => 'object',
88                    'properties' => array(
89                        'settings' => array(
90                            'type'        => 'object',
91                            'description' => 'Settings object in theme.json format.',
92                            'properties'  => array(
93                                'color'      => array(
94                                    'type'        => 'object',
95                                    'description' => 'Color settings.',
96                                    'properties'  => array(
97                                        'palette' => array(
98                                            'type'        => 'object',
99                                            'description' => 'Palette settings.',
100                                            'properties'  => array(
101                                                'theme' => array(
102                                                    'type' => 'array',
103                                                    'description' => 'Array of theme palette color entries. Only include slugs you are changing.',
104                                                    'items' => array(
105                                                        'type'       => 'object',
106                                                        'properties' => array(
107                                                            'slug'  => array(
108                                                                'type'        => 'string',
109                                                                'description' => 'Color slug: base, base-midtone, contrast, contrast-midtone, accent-1 through accent-6.',
110                                                            ),
111                                                            'color' => array(
112                                                                'type'        => 'string',
113                                                                'description' => 'Hex color value (e.g. #0B3D5B).',
114                                                            ),
115                                                            'name'  => array(
116                                                                'type'        => 'string',
117                                                                'description' => 'Display name for the color.',
118                                                            ),
119                                                        ),
120                                                        'required' => array( 'slug', 'color', 'name' ),
121                                                    ),
122                                                ),
123                                            ),
124                                        ),
125                                    ),
126                                ),
127                                'typography' => array(
128                                    'type'        => 'object',
129                                    'description' => 'Typography settings (fontFamilies, fontSizes).',
130                                ),
131                                'spacing'    => array(
132                                    'type'        => 'object',
133                                    'description' => 'Spacing settings.',
134                                ),
135                            ),
136                        ),
137                        'styles'   => array(
138                            'type'        => 'object',
139                            'description' => 'Styles object containing CSS-like declarations for root, elements, and blocks.',
140                        ),
141                    ),
142                    'anyOf'      => array(
143                        array( 'required' => array( 'settings' ) ),
144                        array( 'required' => array( 'styles' ) ),
145                    ),
146                ),
147                'execute_callback'    => array( $this, 'execute_update_global_styles' ),
148                'permission_callback' => fn() => current_user_can( 'edit_theme_options' ),
149                'meta'                => array(
150                    'annotations' => array(
151                        'readonly'    => false,
152                        'destructive' => true,
153                        'idempotent'  => true,
154                    ),
155                    'mcp'         => array(
156                        'public' => true,
157                        'type'   => 'tool',
158                    ),
159                ),
160            )
161        );
162    }
163
164    /**
165     * Register ability to get active global styles for the current theme
166     *
167     * @return void
168     */
169    private function register_get_active_global_styles(): void {
170        blu_register_ability(
171            'blu/get-active-global-styles',
172            array(
173                'label'               => 'Get Active Global Styles',
174                'description'         => 'Get the currently active global styles for the current theme, including colors, typography, and spacing. Use for: "show colors", "what fonts are available", "current palette", "list styles". Returns the full styles object with all active customizations.',
175                'category'            => 'blu-mcp',
176                'input_schema'        => array(
177                    'type' => 'object',
178                ),
179                'execute_callback'    => function () {
180                    $global_styles = wp_get_global_styles();
181
182                    return is_array( $global_styles ) && ! empty( $global_styles )
183                        ? blu_prepare_ability_response( 200, $global_styles )
184                        : blu_prepare_ability_response( 404, 'No active global styles found.' );
185                },
186                'permission_callback' => fn() => current_user_can( 'edit_theme_options' ),
187                'meta'                => array(
188                    'annotations' => array(
189                        'readonly'    => true,
190                        'destructive' => false,
191                        'idempotent'  => true,
192                    ),
193                ),
194            )
195        );
196    }
197
198    /**
199     * Register ability to get active global styles ID for the current theme
200     *
201     * @return void
202     */
203    private function register_get_active_global_styles_id(): void {
204        blu_register_ability(
205            'blu/get-active-global-styles-id',
206            array(
207                'label'               => 'Get Active Global Styles ID',
208                'description'         => 'Get the active global styles post ID for the current theme. Only use this when you need the ID for reference — do NOT call this before blu/update-global-styles, which resolves the ID automatically. Returns an object with the numeric ID.',
209                'category'            => 'blu-mcp',
210                'input_schema'        => array(
211                    'type' => 'object',
212                ),
213                'execute_callback'    => function () {
214                    $id = \WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
215
216                    return is_int( $id ) && $id > 0
217                        ? blu_prepare_ability_response( 200, array( 'id' => $id ) )
218                        : blu_prepare_ability_response( 404, 'No active global styles ID found.' );
219                },
220                'permission_callback' => fn() => current_user_can( 'edit_theme_options' ),
221                'meta'                => array(
222                    'annotations' => array(
223                        'readonly'    => true,
224                        'destructive' => false,
225                        'idempotent'  => true,
226                    ),
227                ),
228            )
229        );
230    }
231
232    /**
233     * Execute update global styles ability.
234     *
235     * Sends the requested settings/styles to the WP REST endpoint, then diffs the
236     * stored result against the request so callers can see what actually landed.
237     *
238     * The standardized response always includes (alongside the usual statusCode /
239     * status / message fields) an `applied` array of dot-paths that were written
240     * and a `not_applied` array of dot-paths that were sent but did not take
241     * effect. Each not_applied entry carries a `path` and a `reason` so the
242     * caller can self-correct without round-tripping through a follow-up read.
243     *
244     * @param array $input The input parameters.
245     * @return array The result.
246     */
247    public function execute_update_global_styles( array $input = array() ): array {
248        if ( ! isset( $input['settings'] ) && ! isset( $input['styles'] ) ) {
249            return blu_prepare_ability_response( 400, 'Settings or styles object is required' );
250        }
251
252        $global_styles_id = \WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
253
254        if ( ! $global_styles_id ) {
255            return blu_prepare_ability_response( 500, 'Could not find global styles post' );
256        }
257
258        $request = new \WP_REST_Request( 'POST', '/wp/v2/global-styles/' . $global_styles_id );
259
260        // Prepare the update data.
261        $data = array();
262        if ( isset( $input['settings'] ) ) {
263            $data['settings'] = $input['settings'];
264        }
265        if ( isset( $input['styles'] ) ) {
266            $data['styles'] = $input['styles'];
267        }
268
269        $request->set_body_params( $data );
270        $response = rest_do_request( $request );
271
272        $result = blu_standardize_rest_response( $response );
273
274        // Only attach the applied/not_applied diff on a successful 2xx response.
275        // On error, the message already carries the REST error string; surfacing
276        // a diff against an unwritten post would be misleading.
277        if ( isset( $result['statusCode'] ) && $result['statusCode'] >= 200 && $result['statusCode'] < 300 ) {
278            $stored = is_array( $result['message'] ?? null ) ? $result['message'] : array();
279            $diff   = $this->diff_requested_vs_stored( $data, $stored );
280
281            $result['applied']     = $diff['applied'];
282            $result['not_applied'] = $diff['not_applied'];
283        }
284
285        return $result;
286    }
287
288    /**
289     * Compare what was requested against what the stored post actually contains.
290     *
291     * Walks every leaf in `$requested` and looks up the same path in the stored
292     * settings/styles payload returned by the REST endpoint. Leaves whose stored
293     * value matches the requested value land in `applied`; everything else lands
294     * in `not_applied` with a human-readable reason — wrong key path, dropped
295     * unknown key, or preset slug that was never registered.
296     *
297     * @param array $requested Top-level payload submitted to the REST endpoint (keys: settings, styles).
298     * @param array $stored    Stored post payload returned by the REST endpoint.
299     * @return array{applied: string[], not_applied: array<int, array{path: string, reason: string}>}
300     */
301    private function diff_requested_vs_stored( array $requested, array $stored ): array {
302        $applied     = array();
303        $not_applied = array();
304
305        // Collect preset slugs FIRST, across the full request (top-level AND
306        // per-block settings). A reference to a slug registered under
307        // settings.blocks.<block>.typography.fontFamilies must not be flagged
308        // as unknown.
309        $preset_slugs = $this->collect_known_preset_slugs( $requested, $stored );
310
311        $this->walk_leaves(
312            $requested,
313            '',
314            function ( string $path, $requested_value ) use ( $stored, $preset_slugs, &$applied, &$not_applied ) {
315                // Application-shaped leaves placed anywhere under settings.*
316                // (e.g. settings.typography.fontFamily, or
317                // settings.blocks.core/paragraph.typography.fontFamily) have
318                // no effect — they belong under the parallel styles.* path.
319                // Flag this before consulting storage: even if WP kept the
320                // value byte-for-byte, it is rendered as inert metadata.
321                $misplaced = $this->detect_misplaced_application( $path, $requested_value );
322                if ( null !== $misplaced ) {
323                    $not_applied[] = array(
324                        'path'   => $path,
325                        'reason' => $misplaced,
326                    );
327                    return;
328                }
329
330                $stored_value = $this->lookup_path( $stored, $path );
331
332                // Even when the value lands in storage byte-for-byte, a preset
333                // reference that points at nothing (wrong token, or unknown
334                // slug) will not resolve at render time. Surface it as
335                // not_applied so the caller does not claim success on a no-op.
336                $preset_problem = $this->detect_unresolved_preset_reference( $path, $requested_value, $preset_slugs );
337                if ( null !== $preset_problem ) {
338                    $not_applied[] = array(
339                        'path'   => $path,
340                        'reason' => $preset_problem,
341                    );
342                    return;
343                }
344
345                if ( $this->values_match( $requested_value, $stored_value ) ) {
346                    $applied[] = $path;
347                    return;
348                }
349
350                $not_applied[] = array(
351                    'path'   => $path,
352                    'reason' => $this->explain_missing_leaf( $stored_value ),
353                );
354            }
355        );
356
357        return array(
358            'applied'     => $applied,
359            'not_applied' => $not_applied,
360        );
361    }
362
363    /**
364     * Detect a leaf placed under `settings.*` whose terminal sub-path is a
365     * scalar application key (the kind that belongs under `styles.*`). Covers
366     * both the top-level case (`settings.typography.fontFamily`) and the
367     * per-block case (`settings.blocks.<block>.typography.fontFamily`) —
368     * both produce inert metadata that has no visual effect.
369     *
370     * Deliberately narrow: only catches high-confidence "I tried to set the
371     * active font/color by writing to settings" mistakes. Real registrations
372     * (`settings.typography.fontFamilies` plural, `settings.color.palette`,
373     * `settings.appearanceTools`, per-block analogues) are NOT flagged.
374     *
375     * When the value itself uses the wrong preset token (`var:preset|font|...`
376     * instead of `var:preset|font-family|...`), the reason includes that
377     * correction too — the agent should not need a second round-trip to fix
378     * two adjacent mistakes in the same leaf.
379     *
380     * @param string $path  Dot-path of the leaf being visited.
381     * @param mixed  $value Requested leaf value.
382     * @return string|null Reason text, or null when the leaf is not a misplaced application.
383     */
384    private function detect_misplaced_application( string $path, $value ): ?string {
385        $app_keys = 'typography\.font(?:Family|Size|Style|Weight)'
386            . '|typography\.(?:lineHeight|letterSpacing|textDecoration|textTransform)'
387            . '|color\.(?:background|text|gradient)';
388
389        // Two shapes, captured into the same `sub` group:
390        // settings.<sub>
391        // settings.blocks.<block>.<sub>
392        $pattern = "#^settings\\.(?:blocks\\.(?P<block>[^.]+)\\.)?(?P<sub>{$app_keys})$#";
393
394        if ( ! preg_match( $pattern, $path, $m ) ) {
395            return null;
396        }
397
398        $block        = $m['block'] ?? '';
399        $sub          = $m['sub'];
400        $target_path  = '' === $block
401            ? "styles.{$sub}"
402            : "styles.blocks.{$block}.{$sub}";
403        $source_scope = '' === $block ? 'settings.*' : 'settings.blocks.*';
404
405        $reason = "'{$path}' applies a value under {$source_scope} — that subtree is treated as registration metadata, so the value will not render. Move it to '{$target_path}' and resend.";
406
407        // Adjacent mistake: wrong preset token. Mention the fix in the same
408        // reason so the agent does not have to round-trip twice.
409        if ( is_string( $value ) && preg_match( '/^var:preset\|font\|(.+)$/', $value, $vm ) ) {
410            $reason .= " The value also uses the wrong preset token — use 'var:preset|font-family|{$vm[1]}' (not 'font') when you resend.";
411        }
412
413        return $reason;
414    }
415
416    /**
417     * Recursively walk every scalar/leaf inside `$value`, invoking `$visit` with
418     * a dot-delimited path. Numerically-indexed lists (e.g. fontFamilies) are
419     * keyed by `slug` when each item has one so paths stay stable across
420     * reorderings — otherwise we fall back to the numeric index.
421     *
422     * @param mixed    $value Node to walk.
423     * @param string   $path  Path accumulated so far.
424     * @param callable $visit Receives (path, leaf_value).
425     * @return void
426     */
427    private function walk_leaves( $value, string $path, callable $visit ): void {
428        if ( is_array( $value ) ) {
429            // An empty array carries no leaves to compare; do not visit, do not
430            // recurse — it would falsely register as a missing scalar.
431            if ( empty( $value ) ) {
432                return;
433            }
434
435            $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 );
436
437            foreach ( $value as $key => $child ) {
438                if ( $is_list ) {
439                    // Slugs are used as a stable identifier in the path, but
440                    // only when they look like a slug. Anything containing
441                    // path-significant characters (`.`, `[`, `]`, `=`) falls
442                    // back to the numeric index so lookup_path does not
443                    // mis-parse.
444                    $slug      = is_array( $child ) && isset( $child['slug'] ) && is_string( $child['slug'] ) ? $child['slug'] : null;
445                    $child_key = ( null !== $slug && preg_match( '/^[A-Za-z0-9_-]+$/', $slug ) )
446                        ? '[slug=' . $slug . ']'
447                        : '[' . $key . ']';
448                } else {
449                    $child_key = (string) $key;
450                }
451
452                $next_path = '' === $path ? $child_key : $path . ( str_starts_with( $child_key, '[' ) ? $child_key : '.' . $child_key );
453                $this->walk_leaves( $child, $next_path, $visit );
454            }
455            return;
456        }
457
458        $visit( $path, $value );
459    }
460
461    /**
462     * Look up the value at `$path` inside `$haystack`. Returns null when any
463     * segment is missing. Mirrors the path syntax produced by `walk_leaves`
464     * (dot-delimited keys, `[slug=...]` and `[N]` for list items).
465     *
466     * @param array  $haystack Stored payload.
467     * @param string $path     Dot-delimited path.
468     * @return mixed Value at the path, or null when absent.
469     */
470    private function lookup_path( array $haystack, string $path ) {
471        if ( '' === $path ) {
472            return $haystack;
473        }
474
475        // Split on '.' and '[' boundaries while preserving the bracket segment.
476        preg_match_all( '/\[[^\]]+\]|[^.\[]+/', $path, $matches );
477        $segments = $matches[0];
478
479        $cursor = $haystack;
480        foreach ( $segments as $segment ) {
481            // WP_REST_Global_Styles_Controller emits stdClass for empty
482            // settings/styles subtrees. Coerce so the traversal does not
483            // false-negative every leaf below.
484            if ( is_object( $cursor ) ) {
485                $cursor = (array) $cursor;
486            }
487            if ( ! is_array( $cursor ) ) {
488                return null;
489            }
490
491            if ( str_starts_with( $segment, '[' ) ) {
492                // WP normalises user-supplied preset lists (font families,
493                // colours, etc.) into `{custom, theme, default}` "origin"
494                // buckets. Callers post a flat list; the stored result is
495                // wrapped. Flatten before any list-indexed lookup so the
496                // requested path still resolves.
497                $cursor = $this->flatten_origin_buckets( $cursor );
498                if ( ! is_array( $cursor ) ) {
499                    return null;
500                }
501
502                if ( str_starts_with( $segment, '[slug=' ) ) {
503                    $slug  = substr( $segment, 6, -1 );
504                    $found = null;
505                    foreach ( $cursor as $item ) {
506                        $item = is_object( $item ) ? (array) $item : $item;
507                        if ( is_array( $item ) && isset( $item['slug'] ) && $item['slug'] === $slug ) {
508                            $found = $item;
509                            break;
510                        }
511                    }
512                    if ( null === $found ) {
513                        return null;
514                    }
515                    $cursor = $found;
516                    continue;
517                }
518
519                $index = (int) substr( $segment, 1, -1 );
520                if ( ! array_key_exists( $index, $cursor ) ) {
521                    return null;
522                }
523                $cursor = $cursor[ $index ];
524                continue;
525            }
526
527            if ( ! array_key_exists( $segment, $cursor ) ) {
528                return null;
529            }
530            $cursor = $cursor[ $segment ];
531        }
532
533        return $cursor;
534    }
535
536    /**
537     * Loose equality for diff comparison. Scalars compare with `==` so that
538     * `"42"` and `42` agree (WP REST sometimes restringifies numerics). Arrays
539     * compare structurally after stringification of leaves; we only reach this
540     * helper for scalars in practice because `walk_leaves` recurses into arrays
541     * before invoking the visitor.
542     *
543     * @param mixed $requested Requested leaf value.
544     * @param mixed $stored    Stored leaf value.
545     * @return bool
546     */
547    private function values_match( $requested, $stored ): bool {
548        if ( null === $requested && null === $stored ) {
549            return true;
550        }
551        if ( null === $requested || null === $stored ) {
552            return false;
553        }
554        if ( is_scalar( $requested ) && is_scalar( $stored ) ) {
555            // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- numeric/string drift after REST round-trip is expected
556            return (string) $requested === (string) $stored;
557        }
558        return $requested === $stored;
559    }
560
561    /**
562     * Build the set of preset slugs that exist after this update, by union-ing
563     * the slugs already in the stored payload with the slugs the request is
564     * adding. Used to detect `var:preset|*|<slug>` references that point at
565     * nothing.
566     *
567     * The result is a map keyed by `<kind>|<slug>` (e.g. `font-family|fira-code`)
568     * because preset namespaces are independent — a color slug doesn't satisfy
569     * a font-family reference.
570     *
571     * @param array $requested Requested payload.
572     * @param array $stored    Stored payload returned by REST.
573     * @return array<string, bool>
574     */
575    private function collect_known_preset_slugs( array $requested, array $stored ): array {
576        $slugs = array();
577
578        // Each collector points at the parent of the preset list. WP wraps the
579        // list in `{custom, theme, default}` origin buckets when storing user
580        // updates, so we flatten across all origins to collect every defined
581        // slug regardless of where it was registered (top-level OR per-block,
582        // custom OR theme-provided).
583        $collectors = array(
584            'font-family' => array( 'typography', 'fontFamilies' ),
585            'font-size'   => array( 'typography', 'fontSizes' ),
586            'color'       => array( 'color', 'palette' ),
587        );
588
589        foreach ( array( $requested, $stored ) as $source ) {
590            $source = $this->coerce_to_array( $source );
591            foreach ( $this->iter_per_block_and_global_settings( $source ) as $settings ) {
592                foreach ( $collectors as $kind => $path ) {
593                    $node = $settings;
594                    foreach ( $path as $segment ) {
595                        $node = is_object( $node ) ? (array) $node : $node;
596                        if ( ! is_array( $node ) || ! isset( $node[ $segment ] ) ) {
597                            $node = null;
598                            break;
599                        }
600                        $node = $node[ $segment ];
601                    }
602                    $node = $this->flatten_origin_buckets( $node );
603                    if ( ! is_array( $node ) ) {
604                        continue;
605                    }
606                    foreach ( $node as $item ) {
607                        $item = is_object( $item ) ? (array) $item : $item;
608                        if ( is_array( $item ) && isset( $item['slug'] ) && is_string( $item['slug'] ) ) {
609                            $slugs[ $kind . '|' . $item['slug'] ] = true;
610                        }
611                    }
612                }
613            }
614        }
615
616        return $slugs;
617    }
618
619    /**
620     * Unwrap WordPress's `{custom, theme, default}` "origin" buckets into a
621     * single flat list. When the caller posts a flat preset list (e.g.
622     * `settings.typography.fontFamilies: [...]`) WP normalises the stored
623     * shape to `{custom: [...]}`. The diff walker uses flat list semantics
624     * (`[slug=...]`, `[N]`), so any indexed lookup against a stored payload
625     * must flatten the buckets first or it will false-negative every item.
626     *
627     * Behaviour:
628     *  - List in → list out (passes through).
629     *  - Dict with any of {custom, theme, default} keys → concatenated values.
630     *  - Anything else → returned unchanged.
631     *
632     * Coerces stdClass nodes before inspecting them.
633     *
634     * @param mixed $node Subtree to flatten.
635     * @return mixed Flat list when wrapping detected, otherwise $node unchanged.
636     */
637    private function flatten_origin_buckets( $node ) {
638        if ( is_object( $node ) ) {
639            $node = (array) $node;
640        }
641        if ( ! is_array( $node ) || empty( $node ) ) {
642            return $node;
643        }
644        // Already a list — nothing to unwrap.
645        if ( array_keys( $node ) === range( 0, count( $node ) - 1 ) ) {
646            return $node;
647        }
648        $origin_keys = array_intersect( array_keys( $node ), array( 'custom', 'theme', 'default' ) );
649        if ( empty( $origin_keys ) ) {
650            return $node;
651        }
652        $flat = array();
653        foreach ( $origin_keys as $k ) {
654            $bucket = is_object( $node[ $k ] ) ? (array) $node[ $k ] : $node[ $k ];
655            if ( is_array( $bucket ) ) {
656                foreach ( $bucket as $item ) {
657                    $flat[] = $item;
658                }
659            }
660        }
661        return $flat;
662    }
663
664    /**
665     * Detect a preset reference (`var:preset|<kind>|<slug>`) that will not
666     * resolve at render time — either because the token is wrong (`font`
667     * instead of `font-family`) or because the slug isn't registered anywhere.
668     *
669     * Only fires on `styles.*` leaves: under `settings.*` a similar-looking
670     * string would be data, not an applied reference.
671     *
672     * @param string              $path         Dot-path of the leaf.
673     * @param mixed               $value        Requested leaf value.
674     * @param array<string, bool> $preset_slugs Known preset slugs keyed by `<kind>|<slug>`.
675     * @return string|null Human-readable reason, or null when nothing is wrong.
676     */
677    private function detect_unresolved_preset_reference( string $path, $value, array $preset_slugs ): ?string {
678        if ( ! is_string( $value ) || ! str_starts_with( $path, 'styles.' ) ) {
679            return null;
680        }
681
682        // WordPress accepts two interchangeable preset forms:
683        // var:preset|<kind>|<slug>
684        // var(--wp--preset--<kind>--<slug>)
685        // LLMs reach for the second form when writing CSS-like values, so
686        // check both.
687        if ( preg_match( '/^var:preset\|([^|]+)\|(.+)$/', $value, $m ) ) {
688            $kind = $m[1];
689            $slug = $m[2];
690        } elseif ( preg_match( '/^var\(--wp--preset--([a-z0-9-]+)--([a-z0-9-]+)\)$/', $value, $m ) ) {
691            $kind = $m[1];
692            $slug = $m[2];
693        } else {
694            return null;
695        }
696
697        if ( 'font' === $kind ) {
698            return "Preset reference '{$value}' uses the wrong token — use 'var:preset|font-family|{$slug}' (not 'font'). WordPress will not resolve 'var:preset|font|*' and the stored value will have no visual effect.";
699        }
700
701        if ( ! isset( $preset_slugs[ $kind . '|' . $slug ] ) ) {
702            $snippet = $this->build_registration_snippet( $kind, $slug );
703            return "Preset reference '{$value}' points at an unknown slug — the value was stored but will not resolve at render time. To fix it, REGISTER the slug AND keep this styles application in the SAME call. Add this to your payload (fill in the placeholder fields), then resend together: {$snippet}";
704        }
705
706        return null;
707    }
708
709    /**
710     * Build a copy-paste-ready JSON snippet for the registration that an
711     * unresolved preset reference needs. The caller pastes this into the
712     * `settings.*` half of the payload, fills in the few placeholder fields
713     * (display name, CSS family stack, hex colour, size), and resends with
714     * the original `styles.*` application still attached.
715     *
716     * Slug → display name uses title-case of the dash-separated slug
717     * ("fira-code" → "Fira Code") as a reasonable default the caller can
718     * override.
719     *
720     * @param string $kind Preset kind: font-family | color | font-size | …
721     * @param string $slug Preset slug.
722     * @return string Snippet that can be embedded inline in a hint string.
723     */
724    private function build_registration_snippet( string $kind, string $slug ): string {
725        $display = ucwords( str_replace( array( '-', '_' ), ' ', $slug ) );
726
727        switch ( $kind ) {
728            case 'font-family':
729                return wp_json_encode(
730                    array(
731                        'settings' => array(
732                            'typography' => array(
733                                'fontFamilies' => array(
734                                    array(
735                                        'slug'       => $slug,
736                                        'name'       => $display,
737                                        'fontFamily' => "\"{$display}\", <fallback stack — e.g. monospace or sans-serif>",
738                                    ),
739                                ),
740                            ),
741                        ),
742                    )
743                );
744            case 'color':
745                return wp_json_encode(
746                    array(
747                        'settings' => array(
748                            'color' => array(
749                                'palette' => array(
750                                    array(
751                                        'slug'  => $slug,
752                                        'name'  => $display,
753                                        'color' => '<#hex value>',
754                                    ),
755                                ),
756                            ),
757                        ),
758                    )
759                );
760            case 'font-size':
761                return wp_json_encode(
762                    array(
763                        'settings' => array(
764                            'typography' => array(
765                                'fontSizes' => array(
766                                    array(
767                                        'slug' => $slug,
768                                        'name' => $display,
769                                        'size' => '<CSS size — e.g. 1rem>',
770                                    ),
771                                ),
772                            ),
773                        ),
774                    )
775                );
776            default:
777                return wp_json_encode(
778                    array(
779                        'settings' => array(
780                            $kind => array(
781                                array(
782                                    'slug' => $slug,
783                                    'name' => $display,
784                                ),
785                            ),
786                        ),
787                    )
788                );
789        }
790    }
791
792    /**
793     * Reason text for a leaf whose requested value did not land in storage.
794     * Kept deliberately small — the caller has already ruled out the preset
795     * cases and the misplaced-subtree case before reaching this helper.
796     *
797     * @param mixed $stored Stored value at the path (or null when absent).
798     * @return string
799     */
800    private function explain_missing_leaf( $stored ): string {
801        if ( null === $stored ) {
802            return 'Path was not written to the stored global styles. Most likely the key is not part of theme.json schema, or it was overridden by a sanitization step.';
803        }
804
805        if ( is_scalar( $stored ) ) {
806            $stored_preview = (string) $stored;
807        } else {
808            $encoded        = (string) wp_json_encode( $stored );
809            $stored_preview = strlen( $encoded ) > 200 ? substr( $encoded, 0, 200 ) . '…' : $encoded;
810        }
811        return "Stored value differs from requested. Stored: {$stored_preview}.";
812    }
813
814    /**
815     * Shallow object→array coercion for the top level of a REST payload, where
816     * `WP_REST_Global_Styles_Controller` may emit `stdClass` for empty
817     * subtrees. Deeper coercion happens inline at each traversal step.
818     *
819     * @param mixed $value Value to coerce.
820     * @return mixed Array when $value is array-like, otherwise $value unchanged.
821     */
822    private function coerce_to_array( $value ) {
823        if ( is_object( $value ) ) {
824            return (array) $value;
825        }
826        return $value;
827    }
828
829    /**
830     * Yield every settings-shaped subtree in a global-styles payload — the
831     * top-level `settings` and each `settings.blocks.<block>` per-block
832     * override. Used to collect preset slugs from every location WordPress
833     * recognises, not just the root.
834     *
835     * @param array $payload Coerced top-level payload.
836     * @return iterable<array>
837     */
838    private function iter_per_block_and_global_settings( array $payload ): iterable {
839        $settings = $payload['settings'] ?? array();
840        $settings = is_object( $settings ) ? (array) $settings : $settings;
841        if ( ! is_array( $settings ) ) {
842            return;
843        }
844
845        yield $settings;
846
847        $blocks = $settings['blocks'] ?? array();
848        $blocks = is_object( $blocks ) ? (array) $blocks : $blocks;
849        if ( ! is_array( $blocks ) ) {
850            return;
851        }
852
853        foreach ( $blocks as $block_settings ) {
854            $block_settings = is_object( $block_settings ) ? (array) $block_settings : $block_settings;
855            if ( is_array( $block_settings ) ) {
856                yield $block_settings;
857            }
858        }
859    }
860}