PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
fluent-cart / app / Services / Theme / ThemePalette.php

ThemePalette.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.1, at app/Services/Theme/ThemePalette.php

1,462 lines 55.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Theme;
4
5 use FluentCart\App\Services\Theme\Readers\AstraSettingsReader;
6 use FluentCart\App\Services\Theme\Readers\BlocksySettingsReader;
7 use FluentCart\App\Services\Theme\Readers\BricksSettingsReader;
8 use FluentCart\App\Services\Theme\Readers\DiviSettingsReader;
9 use FluentCart\App\Services\Theme\Readers\GeneratePressSettingsReader;
10 use FluentCart\App\Services\Theme\Readers\KadenceSettingsReader;
11 use FluentCart\App\Services\Theme\Readers\ThemeRadiusReader;
12 use FluentCart\Framework\Support\Arr;
13
14 /**
15 * Reads the active theme's colour palette and resolves it into the semantic
16 * roles the storefront needs.
17 *
18 * A block theme publishes a handful of palette entries in theme.json. The
19 * storefront needs a surface, a body text tone, an accent and a button colour
20 * as anchors, plus the in-between tones — borders, dividers, muted text,
21 * placeholder text — that no theme bothers to declare. Those are derived from
22 * the anchors rather than invented, so the store follows the theme instead of
23 * merely sitting next to it, and keeps following when the palette changes.
24 */
25 class ThemePalette
26 {
27 /**
28 * Palette slugs tried for each anchor role, most specific first.
29 *
30 * These are the slugs themes agree on — `base`/`contrast` come from Twenty
31 * Twenty-Four and the themes that copied it, `primary`/`accent` from the
32 * classic-adjacent ones, and GeneratePress publishes the same names as
33 * references its own stylesheets resolve (measured through its Global
34 * Colors; see vendorValues()).
35 *
36 * Three vendors are supported by name — the ones popular enough to be worth
37 * carrying, each mapping written down from the theme's own sources rather
38 * than inferred from the numbering:
39 *
40 * - Astra numbers 0 brand, 3 body text, 4 background.
41 * - Kadence's theme.json names `theme-palette1` "Accent"; its defaults set
42 * the body font to `palette4` and the content background to `palette9`.
43 * - Blocksy paints its buttons with `palette-color-1`, defaults Base Text
44 * to `palette-color-3`, and its surfaces inherit `palette-color-8`.
45 *
46 * All publish their palette as `var()` references the theme itself
47 * declares on every page, so the reference is written through and the
48 * browser resolves it there. Blocksy's references carry hex fallbacks,
49 * which makes them measurable as shipped; Astra's and Kadence's are bare,
50 * so their current values are read from the vendor's own settings and
51 * attached as the fallback (see vendorValues()) — the same measurable
52 * shape, arrived at from the source the vendor prints the property from.
53 * Either way derived tones and button contrast resolve for real. Should a
54 * reference still measure as nothing, the button is not owned at all (see
55 * resolve()) — never a guessed text colour on an unknown background.
56 *
57 * The vendor slugs sit last so the shared names always win first — a real
58 * colour can also be mixed and measured, a reference can only be written. Every other vendor still takes the stand-aside path
59 * (nothing written, the no-colors marker on the body, the theme's own rules
60 * styling the CTAs), or the `fluent_cart/theme/anchor_map` filter.
61 *
62 * @var array
63 */
64 protected static $anchorCandidates = [
65 'surface' => ['base', 'background', 'white', 'base-2', 'light', 'ast-global-color-4', 'theme-palette9', 'palette-color-8'],
66 'text' => ['contrast', 'text', 'foreground', 'black', 'dark', 'contrast-2', 'ast-global-color-3', 'theme-palette4', 'palette-color-3'],
67 'accent' => ['primary', 'accent', 'brand', 'accent-1', 'theme-1', 'link', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'],
68 'button_bg' => ['primary', 'accent', 'brand', 'contrast', 'accent-1', 'theme-1', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'],
69 ];
70
71 /**
72 * Request-level cache for the normalised palette.
73 *
74 * @var array|null
75 */
76 protected static $cachedPalette = null;
77
78 /**
79 * Request-level cache for the resolved roles.
80 *
81 * @var array|null
82 */
83 protected static $cachedRoles = null;
84
85 /**
86 * Request-level cache for the vendor-declared property values.
87 *
88 * @var array|null
89 */
90 protected static $cachedVendorValues = null;
91
92 /**
93 * Request-level cache for the roles the theme's own settings state.
94 *
95 * @var array|null
96 */
97 protected static $cachedSettingsRoles = null;
98
99 /**
100 * @var array|null
101 */
102 protected static $cachedRadii = null;
103
104 /**
105 * Whether the site switched the theme's settings reader off: a reader
106 * stated colours and `fluent_cart/theme/settings_roles` returned none.
107 * Set by settingsRoles().
108 *
109 * @var bool
110 */
111 protected static $settingsReaderOff = false;
112
113 /**
114 * Theme settings readers, keyed by the theme name passed to the
115 * `fluent_cart/theme/settings_roles` filter. The first that applies wins.
116 *
117 * Only one theme runs on a site, so the order only matters when several
118 * vendors' functions are loaded at once (the test stubs). The stricter
119 * checks go first: Blocksy's and Kadence's each need a live instance from
120 * the vendor's API, Divi's needs the `$shortname` global to name Divi, and
121 * Bricks' needs its running `Bricks\Theme` singleton, and GeneratePress's
122 * needs its dynamic-CSS printer hooked on `wp_enqueue_scripts`, none of
123 * which a loaded-but-idle stub provides, while Astra's is a bare
124 * `function_exists()` that stays true once its stub is loaded — so Astra
125 * is asked last. Among the strict ones the order is arbitrary.
126 *
127 * @var array
128 */
129 protected static $settingsReaders = [
130 'blocksy' => BlocksySettingsReader::class,
131 'kadence' => KadenceSettingsReader::class,
132 'divi' => DiviSettingsReader::class,
133 'bricks' => BricksSettingsReader::class,
134 'generatepress' => GeneratePressSettingsReader::class,
135 'astra' => AstraSettingsReader::class,
136 ];
137
138 /**
139 * The roles a theme settings reader may state.
140 *
141 * @var array
142 */
143 protected static $settingsRoleKeys = [
144 'surface', 'text', 'accent', 'border',
145 'button_bg', 'button_text', 'button_hover_bg', 'button_hover_text',
146 ];
147
148 /**
149 * Drop the request-level caches.
150 *
151 * Reading theme.json and resolving eleven roles is repeated work within a
152 * request, so both are cached. Anything that changes what those reads would
153 * return — switching theme, editing the palette, a filter registered after
154 * a first read — has to clear them or it keeps seeing the old palette.
155 *
156 * @return void
157 */
158 public static function clearCache(): void
159 {
160 self::$cachedPalette = null;
161 self::$cachedRoles = null;
162 self::$cachedVendorValues = null;
163 self::$cachedSettingsRoles = null;
164 self::$cachedRadii = null;
165 self::$settingsReaderOff = false;
166 }
167
168 /**
169 * The active theme's colour palette, normalised to hex.
170 *
171 * @return array List of ['slug' => ..., 'name' => ..., 'color' => ...].
172 */
173 public static function palette(): array
174 {
175 if (self::$cachedPalette !== null) {
176 return self::$cachedPalette;
177 }
178
179 if (!function_exists('wp_get_global_settings')) {
180 self::$cachedPalette = [];
181
182 return self::$cachedPalette;
183 }
184
185 $raw = wp_get_global_settings(['color', 'palette']);
186
187 self::$cachedPalette = is_array($raw) ? self::normalizePalette($raw) : [];
188
189 return self::$cachedPalette;
190 }
191
192 /**
193 * Flatten whatever shape wp_get_global_settings() returned.
194 *
195 * Depending on the WordPress version this is either a flat list or one
196 * list per origin. The `default` origin is WordPress's own twelve-colour
197 * palette — black, white and the vivid-* set — which every site has
198 * whether or not its theme declares anything. Inheriting from it would
199 * not be inheriting from the theme, and it would let a theme that
200 * publishes nothing report a palette it does not have, so only the
201 * theme's own colours and the user's customisations of them are read.
202 *
203 * @param array $raw
204 * @return array
205 */
206 protected static function normalizePalette(array $raw): array
207 {
208 $groups = [];
209
210 if (isset($raw['default']) || isset($raw['theme']) || isset($raw['custom'])) {
211 // `custom` comes last so a colour edited in the site editor wins
212 // over the theme's declared value for the same slug.
213 foreach (['theme', 'custom'] as $origin) {
214 $originColors = Arr::get($raw, $origin, []);
215
216 if (is_array($originColors) && $originColors) {
217 $groups[] = $originColors;
218 }
219 }
220 } else {
221 $groups[] = $raw;
222 }
223
224 $entries = [];
225
226 foreach ($groups as $colors) {
227 foreach ($colors as $color) {
228 $slug = Arr::get($color, 'slug', '');
229
230 if (!$slug) {
231 continue;
232 }
233
234 $entries[$slug] = [
235 'slug' => (string)$slug,
236 'name' => (string)Arr::get($color, 'name', $slug),
237 'color' => (string)Arr::get($color, 'color', ''),
238 ];
239 }
240 }
241
242 /**
243 * Filter the theme palette offered for colour inheritance.
244 *
245 * @param array $entries List of ['slug', 'name', 'color'].
246 */
247 $entries = apply_filters('fluent_cart/theme/palette', array_values($entries));
248
249 return self::usableEntries($entries);
250 }
251
252 /**
253 * Keep only the entries that name a slug and a colour we can actually read.
254 *
255 * theme.json is not restricted to hex, and several popular themes publish
256 * their palette as `var(--theme-colour-0)` references that cannot be
257 * resolved server-side. Those are dropped rather than guessed: an
258 * unresolvable value handed on as if it were a colour would be mixed into
259 * the derived tones, and a muted text colour mixed from nothing collapses
260 * to the surface it sits on — invisible text rather than an obvious error.
261 *
262 * Applied after the filter as well as before it, because a filter is just
263 * another source of values and gets no more trust than theme.json does.
264 *
265 * @param mixed $entries
266 * @return array
267 */
268 protected static function usableEntries($entries): array
269 {
270 if (!is_array($entries)) {
271 return [];
272 }
273
274 $usable = [];
275
276 foreach ($entries as $entry) {
277 if (!is_array($entry)) {
278 continue;
279 }
280
281 $slug = Arr::get($entry, 'slug', '');
282 $raw = (string)Arr::get($entry, 'color', '');
283 $hex = ColorMath::hex($raw);
284
285 // A literal colour is preferred because it can also be mixed and
286 // measured. A reference cannot be resolved here, but the browser
287 // resolves it perfectly well, so it is kept as something we can
288 // write — and when it carries a hex fallback, that fallback is the
289 // colour it can be measured as too.
290 $value = $hex !== '' ? $hex : self::safeReference($raw);
291
292 // A bare reference to a property the active vendor itself declares
293 // is only unreadable to an outsider: the vendor prints it from its
294 // own settings, and those are one function call away. Attaching
295 // that value as the fallback turns the reference into the
296 // measurable shape — the browser still follows the live property,
297 // the fallback is only what it is measured as here.
298 if ($hex === '' && $value !== '' && self::measurable($value) === '') {
299 $property = substr($value, 4, -1);
300 $vendorHex = (string)Arr::get(self::vendorValues(), $property, '');
301
302 if ($vendorHex !== '') {
303 $value = 'var(' . $property . ', ' . $vendorHex . ')';
304 }
305 }
306
307 if (!$slug || $value === '') {
308 continue;
309 }
310
311 $usable[(string)$slug] = [
312 'slug' => (string)$slug,
313 'name' => (string)Arr::get($entry, 'name', $slug),
314 'color' => self::measurable($value),
315 'value' => $value,
316 ];
317 }
318
319 return array_values($usable);
320 }
321
322 /**
323 * The current values of the properties the active vendor declares, keyed
324 * by property name.
325 *
326 * Each mapping reads the same source the vendor prints the property from,
327 * so customisations are included — the customiser saves into the very
328 * option being read. Astra prints `--ast-global-color-N` from
329 * `astra_get_option('global-color-palette')` (its
330 * `generate_global_palette_style()`), and Kadence prints
331 * `--global-paletteN` from `kadence()->palette_option('paletteN')` (its
332 * styles component; see KadenceSettingsReader::paletteValues()).
333 * Blocksy's editor palette already ships with hex fallbacks, but its Customizer settings point at the bare
334 * `--theme-palette-color-N`, so its palette is read too, from
335 * `blocksy_manager()->colors->get_color_palette()` (the list it prints
336 * the properties from; see BlocksySettingsReader::paletteValues()).
337 * Bricks prints `--bricks-color-{id}` from its colour palette (or the
338 * property a palette colour's raw value names; see
339 * BricksSettingsReader::paletteValues()). GeneratePress prints `--{slug}`
340 * on :root from its Global Colors and publishes them to the editor
341 * palette as bare `var(--{slug})` (see
342 * GeneratePressSettingsReader::paletteValues()).
343 *
344 * The values are only ever used as the fallback half of `var(--x, #hex)`,
345 * so a wrong or stale answer cannot repaint anything — the browser keeps
346 * resolving the live property — it can only mis-measure, which is where
347 * resolve() refusing an unmeasurable button still protects the store.
348 *
349 * @return array Property => hex.
350 */
351 protected static function vendorValues(): array
352 {
353 if (self::$cachedVendorValues !== null) {
354 return self::$cachedVendorValues;
355 }
356
357 $values = [];
358
359 if (function_exists('astra_get_option')) {
360 $palette = astra_get_option('global-color-palette');
361 $colors = is_array($palette) ? Arr::get($palette, 'palette', []) : [];
362
363 foreach ((array)$colors as $index => $color) {
364 $values['--ast-global-color-' . $index] = (string)$color;
365 }
366 }
367
368 foreach (BlocksySettingsReader::paletteValues() as $property => $color) {
369 $values[$property] = $color;
370 }
371
372 foreach (KadenceSettingsReader::paletteValues() as $property => $color) {
373 $values[$property] = $color;
374 }
375
376 foreach (BricksSettingsReader::paletteValues() as $property => $color) {
377 $values[$property] = $color;
378 }
379
380 foreach (GeneratePressSettingsReader::paletteValues() as $property => $color) {
381 $values[$property] = $color;
382 }
383
384 /**
385 * Filter the vendor-declared custom property values used to measure
386 * bare palette references, keyed by property name (`--x` => `#hex`).
387 *
388 * @param array $values Property => colour.
389 */
390 $values = apply_filters('fluent_cart/theme/vendor_values', $values);
391
392 // A filter is just another source of values: only a real property
393 // name paired with a real hex survives. Kadence's palette10 can be an
394 // oklch() expression, which this drops too — an expression cannot be
395 // measured, and it must never ride into a declaration as a fallback.
396 $clean = [];
397
398 if (is_array($values)) {
399 foreach ($values as $property => $color) {
400 $colorHex = ColorMath::hex((string)$color);
401
402 if ($colorHex !== '' && preg_match('/^--[A-Za-z0-9_-]+$/', (string)$property)) {
403 $clean[(string)$property] = $colorHex;
404 }
405 }
406 }
407
408 return self::$cachedVendorValues = $clean;
409 }
410
411 /**
412 * Accept a bare custom-property reference, and nothing more.
413 *
414 * Themes built on the page-builder stack — Astra, Kadence, GeneratePress —
415 * publish their palette as `var(--ast-global-color-0)` rather than as a
416 * literal, because the real value lives in their own settings and is
417 * emitted as a custom property at runtime. Refusing those makes theme
418 * inheritance do nothing on a large share of real stores.
419 *
420 * Only the single-argument form is accepted: no fallback expression, no
421 * nesting, no parentheses beyond the one pair. This string is written into
422 * a declaration on every storefront page, so the grammar is kept narrow
423 * enough that nothing else can ride along inside it.
424 *
425 * @param string $value
426 * @return string The normalised reference, or '' when it is not one.
427 */
428 protected static function safeReference($value): string
429 {
430 $value = trim((string)$value);
431
432 if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*\)$/', $value, $matches)) {
433 return 'var(' . $matches[1] . ')';
434 }
435
436 // One fallback shape is allowed, and only one: a hex colour. Customify
437 // publishes its whole palette as `var(--customify-primary, #0e7c7b)` —
438 // the reference follows the customiser live, and the fallback is the
439 // one kind of fallback that is itself checkable. `red`, expressions and
440 // nested var() stay refused.
441 if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) {
442 return 'var(' . $matches[1] . ', ' . strtolower($matches[2]) . ')';
443 }
444
445 return '';
446 }
447
448 /**
449 * The colour a value can actually be measured as.
450 *
451 * A hex is itself. A reference with a hex fallback is measured as the
452 * fallback — the theme shipped it as the value to use when the property is
453 * missing, which makes it the theme's own best answer for what the colour
454 * is. A bare reference measures as nothing.
455 *
456 * @param string $value
457 * @return string Hex, or ''.
458 */
459 public static function measurable($value): string
460 {
461 $value = (string)$value;
462 $hex = ColorMath::hex($value);
463
464 if ($hex !== '') {
465 return $hex;
466 }
467
468 if (preg_match('/^var\(\s*--[A-Za-z0-9_-]+\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) {
469 return strtolower($matches[1]);
470 }
471
472 return '';
473 }
474
475 /**
476 * Normalise a colour a theme setting states into a value we can write.
477 *
478 * A hex is itself, lowercased. A custom-property reference stays a live
479 * reference; a bare one gains the vendor's current value as its hex
480 * fallback (see vendorValues()), so the browser still follows the
481 * property while PHP measures the fallback. Anything else — rgba(),
482 * named colours, expressions — is refused: the result is written into a
483 * declaration on every storefront page and must pass
484 * FrontendTheme::sanitizeDeclarationValue().
485 *
486 * @param mixed $raw
487 * @return string Hex, `var(--x)`, `var(--x, #hex)`, or ''.
488 */
489 public static function settingValue($raw): string
490 {
491 if (!is_string($raw)) {
492 return '';
493 }
494
495 $raw = trim($raw);
496
497 if (preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $raw)) {
498 return ColorMath::hex($raw);
499 }
500
501 $reference = self::safeReference($raw);
502
503 if ($reference === '' || self::measurable($reference) !== '') {
504 return $reference;
505 }
506
507 $property = substr($reference, 4, -1);
508 $vendorHex = (string)Arr::get(self::vendorValues(), $property, '');
509
510 return $vendorHex !== '' ? 'var(' . $property . ', ' . $vendorHex . ')' : $reference;
511 }
512
513 /**
514 * The colours the active theme's own settings state, by role.
515 *
516 * Palette slots say which colours a theme offers; its settings say which
517 * one the owner put where. A reader for the active theme (see
518 * Readers\ThemeSettingsReader) reports what the owner set — Astra's,
519 * Blocksy's, Kadence's, Divi's, Bricks' or GeneratePress's Accent, Body Text, Content Background, Borders
520 * and Button colours — and those outrank the palette-slot guesses in anchors() and resolve().
521 * Only the site editor's own button (buttonGlobals()) outranks them.
522 *
523 * Roles: surface, text, accent, border, button_bg, button_text,
524 * button_hover_bg, button_hover_text. A role the theme does not state is
525 * absent, and is derived exactly as it would be without a reader.
526 *
527 * @return array Role => hex or reference.
528 */
529 public static function settingsRoles(): array
530 {
531 if (self::$cachedSettingsRoles !== null) {
532 return self::$cachedSettingsRoles;
533 }
534
535 $roles = [];
536 $theme = '';
537
538 foreach (self::$settingsReaders as $name => $reader) {
539 if ($reader::applies()) {
540 $theme = $name;
541 $roles = $reader::roles();
542 break;
543 }
544 }
545
546 $stated = $roles;
547
548 /**
549 * Filter the colours the active theme's settings state, by role.
550 *
551 * Runs whether or not a built-in reader applied, so a theme FluentCart
552 * does not read can be supplied here; return [] to turn the reader off
553 * and fall back to palette slots. Values must be a hex or a
554 * `var(--x)` / `var(--x, #hex)` reference — anything else is dropped,
555 * and a bare reference to a vendor property gains its hex fallback.
556 *
557 * @param array $roles Role => colour. Keys: surface, text, accent,
558 * border, button_bg, button_text,
559 * button_hover_bg, button_hover_text.
560 * @param array $context ['theme' => reader name ('blocksy', 'kadence', 'divi', 'bricks', 'generatepress', 'astra'), or '' when
561 * no built-in reader applies].
562 */
563 $roles = apply_filters('fluent_cart/theme/settings_roles', $roles, [
564 'theme' => $theme,
565 ]);
566
567 // A filter is just another source of values: only known roles with a
568 // writable colour survive.
569 $clean = [];
570
571 if (is_array($roles)) {
572 foreach (self::$settingsRoleKeys as $role) {
573 $value = self::settingValue(Arr::get($roles, $role, ''));
574
575 if ($value !== '') {
576 $clean[$role] = $value;
577 }
578 }
579 }
580
581 // Returning [] for a reader that stated something is the documented
582 // way to switch it off; radii() honours that for the same reader.
583 self::$settingsReaderOff = !empty($stated) && is_array($roles) && $roles === [];
584
585 return self::$cachedSettingsRoles = $clean;
586 }
587
588 /**
589 * The border radii the active theme states, by role.
590 *
591 * theme.json speaks first, as it does for the button colours
592 * (buttonGlobals()): the button element, else the Button block, and the
593 * text-input element, else select, else the Search block — the owner's
594 * site editor and picked style variation over the theme's own, see
595 * radiusGlobals(). Then the first applying settings reader that
596 * implements Readers\ThemeRadiusReader fills the roles the site editor
597 * left unstated — unless the site switched that reader off by returning
598 * [] from `fluent_cart/theme/settings_roles`, which turns off its radii
599 * too. A role nothing states is absent, and FluentCart prints nothing
600 * for it.
601 *
602 * Roles: card, btn, input (RadiusPalette::roles()). Every value is one
603 * length in RadiusPalette::sanitizeLength()'s grammar; anything else is
604 * dropped rather than guessed.
605 *
606 * @return array Role => length (`0`, `8px`, `0.5rem`, `1em`).
607 */
608 public static function radii(): array
609 {
610 if (self::$cachedRadii !== null) {
611 return self::$cachedRadii;
612 }
613
614 $radii = self::radiusGlobals();
615 $readerName = '';
616
617 // A reader the site switched off through `fluent_cart/theme/settings_roles`
618 // states no radius either. The site editor is not a reader and still counts.
619 self::settingsRoles();
620 $readers = self::$settingsReaderOff ? [] : self::$settingsReaders;
621
622 foreach ($readers as $name => $reader) {
623 if (!$reader::applies() || !is_subclass_of($reader, ThemeRadiusReader::class)) {
624 continue;
625 }
626
627 $readerName = $name;
628
629 foreach ((array)$reader::radii() as $role => $value) {
630 if (!isset($radii[$role])) {
631 $radii[$role] = $value;
632 }
633 }
634
635 break;
636 }
637
638 /**
639 * Filter the border radii the active theme states, by role.
640 *
641 * Runs whether or not a built-in reader applied, so a theme FluentCart
642 * does not read can be supplied here; return [] to state none. Every
643 * value is normalised again: only `card`, `btn` and `input` survive,
644 * each as one length (`0`, or a number in px, rem or em).
645 *
646 * @param array $radii Role => length. Keys: card, btn, input.
647 * @param array $context ['theme' => the active template's slug,
648 * 'reader' => the reader that applied ('blocksy', 'kadence',
649 * 'divi', 'bricks', 'astra'), or '' when none did].
650 */
651 $radii = apply_filters('fluent_cart/theme/radius_roles', $radii, [
652 'theme' => get_template(),
653 'reader' => $readerName,
654 ]);
655
656 $clean = [];
657
658 if (is_array($radii)) {
659 foreach (array_keys(RadiusPalette::roles()) as $role) {
660 $value = RadiusPalette::sanitizeLength(Arr::get($radii, $role, ''));
661
662 if ($value !== '') {
663 $clean[$role] = $value;
664 }
665 }
666 }
667
668 return self::$cachedRadii = $clean;
669 }
670
671 /**
672 * The radii theme.json states, from the `theme` and `user` origins — the
673 * owner's (the site editor, or a style variation they picked, both saved
674 * in the user origin) over the theme's. Core's origin is left out, as in
675 * buttonGlobals().
676 *
677 * Within one origin each role reads a chain, the closest match first:
678 * - btn: the button element (`styles.elements.button`, what a plain
679 * `<button class="wp-element-button">` wears — FluentCart's
680 * buttons are plain buttons), then the Button block
681 * (`styles.blocks.core/button`);
682 * - input: the text-input element, the select element, then the Search
683 * block (`styles.blocks.core/search`, whose border styles its
684 * input).
685 * No path is read as the product-card radius: no theme.json path names a
686 * product card, and a featured-image or group radius is not one.
687 *
688 * @return array Role => length, normalised; only stated roles.
689 */
690 protected static function radiusGlobals(): array
691 {
692 $radii = [];
693
694 if (!class_exists('WP_Theme_JSON_Resolver')) {
695 return $radii;
696 }
697
698 $paths = [
699 'btn' => [
700 'styles.elements.button.border.radius',
701 'styles.blocks.core/button.border.radius',
702 ],
703 'input' => [
704 'styles.elements.textInput.border.radius',
705 'styles.elements.select.border.radius',
706 'styles.blocks.core/search.border.radius',
707 ],
708 ];
709
710 foreach (['get_theme_data', 'get_user_data'] as $origin) {
711 if (!method_exists('WP_Theme_JSON_Resolver', $origin)) {
712 continue;
713 }
714
715 $data = \WP_Theme_JSON_Resolver::$origin();
716
717 if (!is_object($data) || !method_exists($data, 'get_raw_data')) {
718 continue;
719 }
720
721 $raw = (array)$data->get_raw_data();
722
723 foreach ($paths as $role => $candidates) {
724 foreach ($candidates as $path) {
725 $stated = Arr::get($raw, $path);
726
727 if ($stated === null || $stated === '' || $stated === []) {
728 continue;
729 }
730
731 // The first stated path ends the chain, writable or not:
732 // a later path only stands in when the earlier is unset.
733 // An unwritable one leaves the role to the theme's reader.
734 $value = self::radiusValue($stated);
735
736 if ($value !== '') {
737 $radii[$role] = $value;
738 } else {
739 unset($radii[$role]);
740 }
741
742 break;
743 }
744 }
745 }
746
747 return $radii;
748 }
749
750 /**
751 * One theme.json radius as a single length.
752 *
753 * - `{"ref": "styles.…"}` points at another value in the merged
754 * theme.json data and is followed (at most three hops);
755 * - a four-corner object is one length only when every corner is;
756 * - preset references resolve to the preset's size:
757 * `var:preset|border-radius|slug` / `var(--wp--preset--border-radius--slug)`
758 * through `settings.border.radiusSizes`, and
759 * `var:preset|spacing|slug` / `var(--wp--preset--spacing--slug)`
760 * through `settings.spacing.spacingSizes` (a fluid `min()`/`clamp()`
761 * size fails the grammar and is dropped);
762 * - `var(--wp--custom--…)` resolves to the custom value it names.
763 *
764 * @param mixed $value
765 * @param int $hops Ref hops left.
766 * @return string Normalised length, or ''.
767 */
768 protected static function radiusValue($value, int $hops = 3): string
769 {
770 if (is_array($value)) {
771 if (isset($value['ref'])) {
772 if ($hops <= 0 || !is_string($value['ref']) || $value['ref'] === '') {
773 return '';
774 }
775
776 return self::radiusValue(Arr::get(self::mergedThemeJson(), $value['ref']), $hops - 1);
777 }
778
779 $corners = [];
780
781 foreach (['topLeft', 'topRight', 'bottomRight', 'bottomLeft'] as $corner) {
782 $corners[] = self::radiusValue(Arr::get($value, $corner, ''), $hops);
783 }
784
785 $corners = array_unique($corners);
786
787 return count($corners) === 1 ? (string)reset($corners) : '';
788 }
789
790 if (!is_string($value)) {
791 return RadiusPalette::sanitizeLength($value);
792 }
793
794 $value = trim($value);
795
796 if (preg_match('/^var:preset\|(border-radius|spacing)\|([\w-]+)$/', $value, $matches)
797 || preg_match('/^var\(\s*--wp--preset--(border-radius|spacing)--([\w-]+)\s*\)$/', $value, $matches)) {
798 $setting = $matches[1] === 'spacing' ? ['spacing', 'spacingSizes'] : ['border', 'radiusSizes'];
799
800 return RadiusPalette::sanitizeLength(self::presetSize($setting, $matches[2]));
801 }
802
803 if (preg_match('/^var\(\s*--wp--custom--([\w-]+)\s*\)$/', $value, $matches)) {
804 return RadiusPalette::sanitizeLength(self::customSetting($matches[1]));
805 }
806
807 return RadiusPalette::sanitizeLength($value);
808 }
809
810 /**
811 * The merged theme.json data (core, theme, user) a `ref` resolves
812 * against — the same data WordPress resolves refs against when it prints.
813 *
814 * @return array
815 */
816 protected static function mergedThemeJson(): array
817 {
818 if (!class_exists('WP_Theme_JSON_Resolver') || !method_exists('WP_Theme_JSON_Resolver', 'get_merged_data')) {
819 return [];
820 }
821
822 $data = \WP_Theme_JSON_Resolver::get_merged_data();
823
824 return is_object($data) && method_exists($data, 'get_raw_data') ? (array)$data->get_raw_data() : [];
825 }
826
827 /**
828 * The size of a preset (`settings.border.radiusSizes` or
829 * `settings.spacing.spacingSizes`), the owner's over the theme's over
830 * core's.
831 *
832 * @param array $setting Settings path, e.g. ['spacing', 'spacingSizes'].
833 * @param string $slug
834 * @return string
835 */
836 protected static function presetSize(array $setting, string $slug): string
837 {
838 if (!function_exists('wp_get_global_settings')) {
839 return '';
840 }
841
842 $sizes = wp_get_global_settings($setting);
843
844 if (!is_array($sizes)) {
845 return '';
846 }
847
848 // Merged settings are keyed by origin; a single origin is a list.
849 $lists = isset($sizes[0]) ? [$sizes] : array_values(array_intersect_key(
850 $sizes,
851 array_flip(['default', 'theme', 'custom'])
852 ));
853
854 $size = '';
855
856 foreach ($lists as $list) {
857 foreach ((array)$list as $preset) {
858 if (is_array($preset) && (string)Arr::get($preset, 'slug') === $slug && is_string(Arr::get($preset, 'size'))) {
859 $size = $preset['size'];
860 }
861 }
862 }
863
864 return $size;
865 }
866
867 /**
868 * The `settings.custom` value a `--wp--custom--a--b` property is printed
869 * from. WordPress names each level by its key in kebab case, joined by
870 * `--`, so the property is walked one level at a time.
871 *
872 * @param string $property The part after `--wp--custom--`.
873 * @return string
874 */
875 protected static function customSetting(string $property): string
876 {
877 if (!function_exists('wp_get_global_settings')) {
878 return '';
879 }
880
881 $node = wp_get_global_settings(['custom']);
882
883 foreach (explode('--', $property) as $segment) {
884 if (!is_array($node)) {
885 return '';
886 }
887
888 $next = null;
889
890 foreach ($node as $key => $child) {
891 if (_wp_to_kebab_case((string)$key) === $segment) {
892 $next = $child;
893 break;
894 }
895 }
896
897 $node = $next;
898 }
899
900 return is_string($node) || is_int($node) || is_float($node) ? (string)$node : '';
901 }
902
903 /**
904 * Whether the active theme gave us anything usable.
905 *
906 * @return bool
907 */
908 public static function available(): bool
909 {
910 return count(self::palette()) > 0;
911 }
912
913 /**
914 * Whether the theme told us anything at all to inherit from.
915 *
916 * A theme can publish a palette we cannot read — several popular ones
917 * declare theirs as `var(--theme-colour-0)` references — and it can set a
918 * global background and text colour without publishing a palette. A site
919 * can also state nothing but its button colours (Styles → Buttons in the
920 * site editor) — a background, a text, or both — and that is still an
921 * explicit configuration to wear.
922 * Only when all of them come back empty is there genuinely nothing to
923 * inherit, and in that case inheriting must stay out of the way rather
924 * than write FluentCart's own colours back to the page and call them the
925 * theme's.
926 *
927 * @return bool
928 */
929 public static function hasUsableSource(): bool
930 {
931 if (self::available()) {
932 return true;
933 }
934
935 $globals = self::globalColors();
936
937 if (Arr::get($globals, 'background', '') !== '' || Arr::get($globals, 'text', '') !== '') {
938 return true;
939 }
940
941 if (self::settingsRoles()) {
942 return true;
943 }
944
945 $button = self::buttonGlobals();
946
947 return Arr::get($button, 'background', '') !== '' || Arr::get($button, 'text', '') !== '';
948 }
949
950 /**
951 * Look up one palette colour by slug.
952 *
953 * @param string $slug
954 * @return string Hex, or '' when the slug is unknown.
955 */
956 public static function color(string $slug): string
957 {
958 return self::lookup($slug, 'color');
959 }
960
961 /**
962 * What one palette slug resolves to for writing into CSS.
963 *
964 * Unlike color(), this can be a custom-property reference — use it when
965 * emitting a declaration, and color() when the value has to be reasoned
966 * about.
967 *
968 * @param string $slug
969 * @return string Hex, a var() reference, or '' when the slug is unknown.
970 */
971 public static function value(string $slug): string
972 {
973 return self::lookup($slug, 'value');
974 }
975
976 /**
977 * Read one field off a palette entry.
978 *
979 * @param string $slug
980 * @param string $field
981 * @return string
982 */
983 protected static function lookup(string $slug, string $field): string
984 {
985 if ($slug === '') {
986 return '';
987 }
988
989 foreach (self::palette() as $entry) {
990 if (Arr::get($entry, 'slug') === $slug) {
991 return (string)Arr::get($entry, $field, '');
992 }
993 }
994
995 return '';
996 }
997
998 /**
999 * The theme's global background and text colours, when it sets them.
1000 *
1001 * @return array ['background' => hex, 'text' => hex]; either may be ''.
1002 */
1003 public static function globalColors(): array
1004 {
1005 $colors = ['background' => '', 'text' => ''];
1006
1007 if (!function_exists('wp_get_global_styles')) {
1008 return $colors;
1009 }
1010
1011 $styles = wp_get_global_styles(['color']);
1012
1013 if (!is_array($styles)) {
1014 return $colors;
1015 }
1016
1017 $colors['background'] = self::resolveReference(Arr::get($styles, 'background', ''));
1018 $colors['text'] = self::resolveReference(Arr::get($styles, 'text', ''));
1019
1020 return $colors;
1021 }
1022
1023 /**
1024 * The button pair the theme declares and the site editor edits.
1025 *
1026 * Styles → Buttons in the site editor saves to
1027 * `styles.elements.button.color`, and block themes ship their own pair
1028 * there in theme.json — the most explicit statement either can make
1029 * about the buttons. The two origins merge per property, the owner's
1030 * edits over the theme's, which is exactly how WordPress itself paints
1031 * the button. Core's origin is left out on purpose: WordPress ships a
1032 * default button colour (#32373c) for every site, and counting that as
1033 * "the theme said something" would mean no theme could ever stand aside.
1034 * Backgrounds usually arrive as a preset reference
1035 * (`var:preset|color|contrast`), which resolves through the palette the
1036 * same way the page pair's do.
1037 *
1038 * The `:hover` pair (`styles.elements.button.:hover.color`) is read the
1039 * same way, into `hover_background` / `hover_text`.
1040 *
1041 * @return array ['background' => hex, 'text' => hex, 'hover_background' => hex,
1042 * 'hover_text' => hex]; any may be ''.
1043 */
1044 public static function buttonGlobals(): array
1045 {
1046 $colors = ['background' => '', 'text' => '', 'hover_background' => '', 'hover_text' => ''];
1047
1048 if (!class_exists('WP_Theme_JSON_Resolver')) {
1049 return $colors;
1050 }
1051
1052 foreach (['get_theme_data', 'get_user_data'] as $origin) {
1053 if (!method_exists('WP_Theme_JSON_Resolver', $origin)) {
1054 continue;
1055 }
1056
1057 $data = \WP_Theme_JSON_Resolver::$origin();
1058
1059 if (!is_object($data) || !method_exists($data, 'get_raw_data')) {
1060 continue;
1061 }
1062
1063 $raw = (array)$data->get_raw_data();
1064 $pairs = [
1065 '' => Arr::get($raw, 'styles.elements.button.color', []),
1066 'hover_' => Arr::get($raw, 'styles.elements.button.:hover.color', []),
1067 ];
1068
1069 foreach ($pairs as $prefix => $pair) {
1070 foreach (['background', 'text'] as $half) {
1071 $value = self::resolveReference((string)Arr::get((array)$pair, $half, ''));
1072
1073 if ($value !== '') {
1074 $colors[$prefix . $half] = $value;
1075 }
1076 }
1077 }
1078 }
1079
1080 return $colors;
1081 }
1082
1083 /**
1084 * Resolve a theme.json colour, which may be a preset reference rather
1085 * than a literal colour.
1086 *
1087 * @param string $value
1088 * @return string Hex, or ''.
1089 */
1090 protected static function resolveReference($value): string
1091 {
1092 $value = trim((string)$value);
1093
1094 if ($value === '') {
1095 return '';
1096 }
1097
1098 if (preg_match('/var:preset\|color\|([\w-]+)/', $value, $matches)) {
1099 return self::color($matches[1]);
1100 }
1101
1102 if (preg_match('/var\(\s*--wp--preset--color--([\w-]+)/', $value, $matches)) {
1103 return self::color($matches[1]);
1104 }
1105
1106 return ColorMath::hex($value);
1107 }
1108
1109 /**
1110 * Which palette slug each anchor role resolves to.
1111 *
1112 * @return array Role => palette slug, '' when nothing matched.
1113 */
1114 public static function anchorMap(): array
1115 {
1116 $map = [];
1117
1118 foreach (self::$anchorCandidates as $role => $slugs) {
1119 $map[$role] = '';
1120
1121 foreach ($slugs as $slug) {
1122 // value(), not color(): a slug the theme publishes only as a
1123 // reference still counts as a colour the theme offers.
1124 if (self::value($slug) !== '') {
1125 $map[$role] = $slug;
1126 break;
1127 }
1128 }
1129 }
1130
1131 /**
1132 * Filter which theme palette slug drives each anchor role.
1133 *
1134 * @param array $map Role => palette slug.
1135 */
1136 return apply_filters('fluent_cart/theme/anchor_map', $map);
1137 }
1138
1139 /**
1140 * The four anchors, exactly as far as the theme could be read.
1141 *
1142 * An anchor the theme did not supply comes back empty rather than standing
1143 * in FluentCart's own colour for it. The stylesheet that uses the property
1144 * already carries that colour as its `var(--fct-x, <fallback>)` fallback,
1145 * so substituting it here would only mean writing the same value twice —
1146 * and writing it under the active theme's name, which is how a store on a
1147 * theme nothing could be read from came to report itself as inheriting
1148 * while wearing FluentCart's palette.
1149 *
1150 * @return array Role => hex, a custom-property reference, or '' when the
1151 * theme said nothing about it.
1152 */
1153 public static function anchors(): array
1154 {
1155 $map = self::anchorMap();
1156 $globals = self::globalColors();
1157 $settings = self::settingsRoles();
1158
1159 // Global styles outrank the palette — the palette lists the AVAILABLE
1160 // colours, global styles say what the body actually RENDERS, and on
1161 // Twenty Twenty-Five's dark style variation those disagree. The
1162 // surface comes from global styles, then the theme's settings, then
1163 // the palette. A body text the owner stated is worn as given below,
1164 // whichever source the surface came from.
1165 $gsSurface = (string)Arr::get($globals, 'background', '');
1166 $gsText = (string)Arr::get($globals, 'text', '');
1167
1168 $setSurface = (string)Arr::get($settings, 'surface', '');
1169
1170 if ($gsSurface !== '' && $gsText !== '') {
1171 // The rendered pair.
1172 $surface = $gsSurface;
1173 $text = $gsText;
1174 } elseif ($setSurface !== '') {
1175 // The pair the theme's settings state (Astra's Content Background
1176 // and Body Text). A surface stated without a text takes the
1177 // palette's own text when it reads there (4.5:1) — Astra saves
1178 // Body Text empty and still means its text swatch — and only an
1179 // unreadable one is replaced by a measured partner.
1180 $surface = $setSurface;
1181 $text = (string)Arr::get($settings, 'text', '');
1182 $surfaceHex = self::measurable($surface);
1183
1184 if ($text === '' && $surfaceHex !== '') {
1185 $paletteText = self::value(Arr::get($map, 'text', ''));
1186 $paletteTextHex = self::measurable($paletteText);
1187
1188 $text = $paletteTextHex !== '' && ColorMath::contrast($surfaceHex, $paletteTextHex) >= 4.5
1189 ? $paletteText
1190 : ColorMath::readableOn($surfaceHex, '#F3F4F6', '#2F3448');
1191 }
1192 } else {
1193 // The published pair.
1194 $surface = self::value(Arr::get($map, 'surface', ''));
1195 $text = self::value(Arr::get($map, 'text', ''));
1196
1197 if ($text === '' && ColorMath::hex($surface) !== '') {
1198 // A lone measurable surface derives its partner by contrast,
1199 // the same way button text does — leaving it unwritten would
1200 // drop the stylesheets' dark fallback text onto a surface
1201 // that may itself be dark.
1202 $text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448');
1203 }
1204 }
1205
1206 // A body text the owner chose is worn as given, even without a stated
1207 // background — global styles first, then the theme's settings.
1208 $statedText = $gsText !== '' ? $gsText : (string)Arr::get($settings, 'text', '');
1209
1210 if ($statedText !== '') {
1211 $text = $statedText;
1212 }
1213
1214 $accent = (string)Arr::get($settings, 'accent', '');
1215
1216 if ($accent === '') {
1217 $accent = self::value(Arr::get($map, 'accent', ''));
1218 }
1219
1220 // What the owner set in the site editor (Styles → Buttons) outranks
1221 // every guess the palette could offer, then the theme's settings. A
1222 // background brings its own text, or gets one measured against it in
1223 // resolve(); a text the owner set alone is worn as given on whatever
1224 // background the button otherwise gets.
1225 $button = self::buttonGlobals();
1226 $buttonText = '';
1227
1228 if ($button['background'] !== '') {
1229 $buttonBg = $button['background'];
1230 $buttonText = $button['text'];
1231 } elseif (Arr::get($settings, 'button_bg', '') !== '') {
1232 // Next, the button the theme's settings state, with its partner.
1233 $buttonBg = (string)$settings['button_bg'];
1234 $buttonText = (string)Arr::get($settings, 'button_text', '');
1235 } else {
1236 $buttonBg = self::value(Arr::get($map, 'button_bg', ''));
1237
1238 if ($buttonBg === '') {
1239 // Not a default — the theme's own accent, which is what a
1240 // theme that names one button colour almost always means by it.
1241 $buttonBg = $accent;
1242 }
1243
1244 $buttonText = $button['text'] !== '' ? $button['text'] : (string)Arr::get($settings, 'button_text', '');
1245 }
1246
1247 return [
1248 'surface' => $surface,
1249 'text' => $text,
1250 'accent' => $accent,
1251 'button_bg' => $buttonBg,
1252 'button_text' => $buttonText,
1253 ];
1254 }
1255
1256 /**
1257 * Resolve every semantic role the theme can actually supply.
1258 *
1259 * The first block is what the theme said. The second is derived from it —
1260 * no theme publishes a hairline border or a placeholder tone, so those are
1261 * mixed out of the body text and the surface.
1262 *
1263 * Deriving needs two real colours to mix between. A reference cannot be
1264 * read here (the browser resolves it on the page, where we are not), and an
1265 * anchor the theme never supplied is not a colour at all, so in both cases
1266 * the derived roles come back empty and nothing is written for them. The
1267 * stylesheet's own fallback is then what applies, which is the same colour
1268 * it would have been given — arrived at once instead of twice.
1269 *
1270 * @return array Role => hex, a reference, or '' when it could not be
1271 * resolved.
1272 */
1273 public static function resolve(): array
1274 {
1275 if (self::$cachedRoles !== null) {
1276 return self::$cachedRoles;
1277 }
1278
1279 $anchors = self::anchors();
1280 $surface = (string)Arr::get($anchors, 'surface', '');
1281 $text = (string)Arr::get($anchors, 'text', '');
1282 $buttonBg = (string)Arr::get($anchors, 'button_bg', '');
1283
1284 // Measured, not taken literally: a reference with a hex fallback mixes
1285 // as the fallback the theme shipped, so a Customify-style palette
1286 // derives instead of standing down.
1287 $textHex = self::measurable($text);
1288 $surfaceHex = self::measurable($surface);
1289 $mixable = $textHex !== '' && $surfaceHex !== '';
1290
1291 $derived = $mixable
1292 ? [
1293 'surface_alt' => ColorMath::mix($textHex, $surfaceHex, 4),
1294 'surface_mute' => ColorMath::mix($textHex, $surfaceHex, 8),
1295 'divider' => ColorMath::mix($textHex, $surfaceHex, 10),
1296 'border' => ColorMath::mix($textHex, $surfaceHex, 18),
1297 'text_placeholder' => ColorMath::mix($textHex, $surfaceHex, 45),
1298 // Muted text is still text: it moves toward the body text only
1299 // as far as it must to read (4.5:1). A mid-grey body text
1300 // (Divi's #666666) otherwise mixed down to 2.92:1.
1301 'text_muted' => ColorMath::readableMix($textHex, $surfaceHex, 68),
1302 ]
1303 : [
1304 'surface_alt' => '',
1305 'surface_mute' => '',
1306 'divider' => '',
1307 'border' => '',
1308 'text_placeholder' => '',
1309 'text_muted' => '',
1310 ];
1311
1312 // A border the theme's settings state is the border (Astra's Borders);
1313 // only an unstated one is mixed.
1314 $settings = self::settingsRoles();
1315
1316 if (Arr::get($settings, 'border', '') !== '') {
1317 $derived['border'] = (string)$settings['border'];
1318 }
1319
1320 // Contrast needs a real colour to measure against for the same reason.
1321 // Colours the owner chose (the site editor's, or a theme settings
1322 // reader's) are worn exactly as given, at any contrast; only a missing
1323 // one is measured. A background nobody chose whose value cannot be
1324 // measured (a bare palette reference) is not worn at all: no readable
1325 // text can be paired with it here, so the stylesheets' own fallback
1326 // styles the button instead. One the owner chose is still worn, with
1327 // nothing invented beside it.
1328 $buttonBgHex = self::measurable($buttonBg);
1329 $ownText = (string)Arr::get($anchors, 'button_text', '');
1330 $stated = self::buttonGlobals();
1331 $buttonStated = $stated['background'] !== '' || Arr::get($settings, 'button_bg', '') !== '';
1332
1333 if ($buttonBgHex !== '') {
1334 $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableText($buttonBgHex);
1335 } else {
1336 if (!$buttonStated) {
1337 $anchors['button_bg'] = '';
1338 }
1339
1340 $derived['button_text'] = $ownText;
1341 }
1342
1343 // The hover follows the button it belongs to: unwritten when the button
1344 // is. The site editor's `:hover` pair outranks any guess; otherwise the
1345 // button colour moves away from itself — darker for a light button,
1346 // lighter for a dark one, and never for a button that cannot be
1347 // measured. Hover text given with it is worn as given; otherwise the
1348 // resting text carries over while it still reads (WCAG 4.5:1), and a
1349 // partner is measured when it does not.
1350 $derived['button_hover_bg'] = '';
1351 $derived['button_hover_text'] = '';
1352
1353 if ($buttonBgHex !== '' || $buttonStated) {
1354 $hoverBg = $stated['hover_background'];
1355 $hoverText = $stated['hover_text'];
1356
1357 // The theme settings' hover belongs to the theme settings' button:
1358 // it is only used when that button is the one being worn, never
1359 // paired onto a button the site editor states.
1360 $settingsButton = $stated['background'] === '' && Arr::get($settings, 'button_bg', '') !== '';
1361
1362 if ($hoverBg === '' && $settingsButton && Arr::get($settings, 'button_hover_bg', '') !== '') {
1363 $hoverBg = (string)$settings['button_hover_bg'];
1364
1365 if ($hoverText === '') {
1366 $hoverText = (string)Arr::get($settings, 'button_hover_text', '');
1367 }
1368 }
1369
1370 if ($hoverBg === '' && $buttonBgHex !== '') {
1371 $hoverBg = ColorMath::shiftFromItself($buttonBgHex, 12);
1372 }
1373
1374 $hoverBgHex = self::measurable($hoverBg);
1375
1376 if ($hoverText === '' && $hoverBgHex !== '') {
1377 $hoverText = self::hoverTextFor($hoverBgHex, (string)$derived['button_text']);
1378 }
1379
1380 $derived['button_hover_bg'] = $hoverBg;
1381 $derived['button_hover_text'] = $hoverText;
1382 }
1383
1384 // The outline secondary button is FluentCart's own invention, and its
1385 // outline is the hierarchy: it always keeps the page surface as its
1386 // background. Wearing the theme's stated pair outright painted BOTH
1387 // CTAs identically (Twenty Twenty-Five states black-on-white, so Add
1388 // to Cart went black beside a black Buy Now). Instead the label
1389 // borrows a half of the pair AS WORN by the primary button (stated
1390 // text, or the measured partner of a lone stated background) —
1391 // whichever half reads better on the page surface, and only when
1392 // that half actually reads (WCAG 4.5:1); otherwise the page's own
1393 // text stays, since a borrowed label that cannot be read matches
1394 // nothing worth matching. Measurement uses the surface's hex, which
1395 // for a reference is its fallback — the theme's own best answer, the
1396 // same trust every derived tone already extends (spec 59).
1397 $derived['secondary_button_bg'] = $surface;
1398 $derived['secondary_button_text'] = $text;
1399
1400 if (self::buttonGlobals()['background'] !== '' && $buttonBgHex !== '' && $surfaceHex !== '') {
1401 $pairText = (string)$derived['button_text'];
1402
1403 $label = ColorMath::contrast($surfaceHex, $pairText) >= ColorMath::contrast($surfaceHex, $buttonBgHex)
1404 ? $pairText
1405 : $buttonBgHex;
1406
1407 if (ColorMath::contrast($surfaceHex, $label) >= 4.5) {
1408 $derived['secondary_button_text'] = $label;
1409 }
1410 }
1411
1412 /**
1413 * Filter the resolved semantic role colours.
1414 *
1415 * @param array $roles Role => hex.
1416 */
1417 self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived));
1418
1419 return self::$cachedRoles;
1420 }
1421
1422 /**
1423 * The text for a hover background nobody gave a text: the button's
1424 * resting text carries over while it still reads (WCAG 4.5:1), and a
1425 * partner is measured when it does not.
1426 *
1427 * @param string $hoverBgHex
1428 * @param string $restingText
1429 * @return string
1430 */
1431 public static function hoverTextFor(string $hoverBgHex, string $restingText): string
1432 {
1433 return ColorMath::contrast($hoverBgHex, $restingText) >= 4.5
1434 ? $restingText
1435 : ColorMath::readableText($hoverBgHex);
1436 }
1437
1438 /**
1439 * A one-line description of what the active theme offers, shown under the
1440 * inherit option so the store owner knows whether it is worth picking.
1441 *
1442 * @return string
1443 */
1444 public static function sourceLabel(): string
1445 {
1446 $count = count(self::palette());
1447
1448 if (!$count) {
1449 return __('The active theme does not publish a colour palette, so inheriting would fall back to FluentCart\'s own colours.', 'fluent-cart');
1450 }
1451
1452 $theme = wp_get_theme();
1453
1454 return sprintf(
1455 /* translators: 1: active theme name, 2: number of palette colours the theme publishes */
1456 _n('%1$s provides %2$d palette colour.', '%1$s provides %2$d palette colours.', $count, 'fluent-cart'),
1457 $theme->get('Name'),
1458 $count
1459 );
1460 }
1461 }
1462