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
← All changes | app/Services/Theme/ThemePalette.php +684 -51 1.6.5 → 1.7.1 View file →
@@ -1,8 +1,15 @@
1 1 <?php
2 2
3 3 namespace FluentCart\App\Services\Theme;
4 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;
5 12 use FluentCart\Framework\Support\Arr;
6 13
7 14 /**
8 15 * Reads the active theme's colour palette and resolves it into the semantic
@@ -22,9 +29,10 @@
22 29 *
23 30 * These are the slugs themes agree on — `base`/`contrast` come from Twenty
24 31 * Twenty-Four and the themes that copied it, `primary`/`accent` from the
25 32 * classic-adjacent ones, and GeneratePress publishes the same names as
26 - * references its own stylesheets resolve.
33 + * references its own stylesheets resolve (measured through its Global
34 + * Colors; see vendorValues()).
27 35 *
28 36 * Three vendors are supported by name — the ones popular enough to be worth
29 37 * carrying, each mapping written down from the theme's own sources rather
30 38 * than inferred from the numbering:
@@ -81,8 +89,64 @@
81 89 */
82 90 protected static $cachedVendorValues = null;
83 91
84 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 + /**
85 149 * Drop the request-level caches.
86 150 *
87 151 * Reading theme.json and resolving eleven roles is repeated work within a
88 152 * request, so both are cached. Anything that changes what those reads would
@@ -95,8 +159,11 @@
95 159 {
96 160 self::$cachedPalette = null;
97 161 self::$cachedRoles = null;
98 162 self::$cachedVendorValues = null;
163 + self::$cachedSettingsRoles = null;
164 + self::$cachedRadii = null;
165 + self::$settingsReaderOff = false;
99 166 }
100 167
101 168 /**
102 169 * The active theme's colour palette, normalised to hex.
@@ -261,10 +328,19 @@
261 328 * option being read. Astra prints `--ast-global-color-N` from
262 329 * `astra_get_option('global-color-palette')` (its
263 330 * `generate_global_palette_style()`), and Kadence prints
264 331 * `--global-paletteN` from `kadence()->palette_option('paletteN')` (its
265 - * styles component). Blocksy needs no entry: its palette already ships
266 - * with hex fallbacks.
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()).
267 343 *
268 344 * The values are only ever used as the fallback half of `var(--x, #hex)`,
269 345 * so a wrong or stale answer cannot repaint anything — the browser keeps
270 346 * resolving the live property — it can only mis-measure, which is where
@@ -288,19 +364,24 @@
288 364 $values['--ast-global-color-' . $index] = (string)$color;
289 365 }
290 366 }
291 367
292 - if (function_exists('Kadence\\kadence')) {
293 - try {
294 - foreach (range(1, 15) as $index) {
295 - $values['--global-palette' . $index] = (string)\Kadence\kadence()->palette_option('palette' . $index);
296 - }
297 - } catch (\Throwable $e) {
298 - // The vendor's API misbehaving means no vendor values — the
299 - // stand-down paths below already handle that.
300 - }
368 + foreach (BlocksySettingsReader::paletteValues() as $property => $color) {
369 + $values[$property] = $color;
301 370 }
302 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 +
303 384 /**
304 385 * Filter the vendor-declared custom property values used to measure
305 386 * bare palette references, keyed by property name (`--x` => `#hex`).
306 387 *
@@ -391,8 +472,436 @@
391 472 return '';
392 473 }
393 474
394 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 + /**
395 904 * Whether the active theme gave us anything usable.
396 905 *
397 906 * @return bool
398 907 */
@@ -406,10 +915,11 @@
406 915 *
407 916 * A theme can publish a palette we cannot read — several popular ones
408 917 * declare theirs as `var(--theme-colour-0)` references — and it can set a
409 918 * global background and text colour without publishing a palette. A site
410 - * can also state nothing but its button pair (Styles → Buttons in the
411 - * site editor), and that is still an explicit configuration to wear.
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.
412 922 * Only when all of them come back empty is there genuinely nothing to
413 923 * inherit, and in that case inheriting must stay out of the way rather
414 924 * than write FluentCart's own colours back to the page and call them the
415 925 * theme's.
@@ -427,9 +937,15 @@
427 937 if (Arr::get($globals, 'background', '') !== '' || Arr::get($globals, 'text', '') !== '') {
428 938 return true;
429 939 }
430 940
431 - return Arr::get(self::buttonGlobals(), 'background', '') !== '';
941 + if (self::settingsRoles()) {
942 + return true;
943 + }
944 +
945 + $button = self::buttonGlobals();
946 +
947 + return Arr::get($button, 'background', '') !== '' || Arr::get($button, 'text', '') !== '';
432 948 }
433 949
434 950 /**
435 951 * Look up one palette colour by slug.
@@ -518,13 +1034,17 @@
518 1034 * Backgrounds usually arrive as a preset reference
519 1035 * (`var:preset|color|contrast`), which resolves through the palette the
520 1036 * same way the page pair's do.
521 1037 *
522 - * @return array ['background' => hex, 'text' => hex]; either may be ''.
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 ''.
523 1043 */
524 1044 public static function buttonGlobals(): array
525 1045 {
526 - $colors = ['background' => '', 'text' => ''];
1046 + $colors = ['background' => '', 'text' => '', 'hover_background' => '', 'hover_text' => ''];
527 1047
528 1048 if (!class_exists('WP_Theme_JSON_Resolver')) {
529 1049 return $colors;
530 1050 }
@@ -539,15 +1059,21 @@
539 1059 if (!is_object($data) || !method_exists($data, 'get_raw_data')) {
540 1060 continue;
541 1061 }
542 1062
543 - $pair = Arr::get((array)$data->get_raw_data(), 'styles.elements.button.color', []);
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 + ];
544 1068
545 - foreach (['background', 'text'] as $half) {
546 - $value = self::resolveReference((string)Arr::get((array)$pair, $half, ''));
1069 + foreach ($pairs as $prefix => $pair) {
1070 + foreach (['background', 'text'] as $half) {
1071 + $value = self::resolveReference((string)Arr::get((array)$pair, $half, ''));
547 1072
548 - if ($value !== '') {
549 - $colors[$half] = $value;
1073 + if ($value !== '') {
1074 + $colors[$prefix . $half] = $value;
1075 + }
550 1076 }
551 1077 }
552 1078 }
553 1079
@@ -627,28 +1153,45 @@
627 1153 public static function anchors(): array
628 1154 {
629 1155 $map = self::anchorMap();
630 1156 $globals = self::globalColors();
1157 + $settings = self::settingsRoles();
631 1158
632 - // Surface and body text resolve as a PAIR from one source, never mixed
633 - // from two. Global styles outrank the palette — the palette lists the
634 - // AVAILABLE colours, global styles say what the body actually RENDERS,
635 - // and on Twenty Twenty-Five's dark style variation those disagree —
636 - // but only when global styles supply BOTH halves. A site that sets
637 - // only its text (light, say, over a CSS-painted dark background) must
638 - // not have that fragment mixed onto the palette's guessed white
639 - // surface: that is light text printed onto a light panel, and the
640 - // stylesheets' paired fallbacks never fire because both values exist.
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.
641 1165 $gsSurface = (string)Arr::get($globals, 'background', '');
642 1166 $gsText = (string)Arr::get($globals, 'text', '');
643 1167
1168 + $setSurface = (string)Arr::get($settings, 'surface', '');
1169 +
644 1170 if ($gsSurface !== '' && $gsText !== '') {
645 1171 // The rendered pair.
646 1172 $surface = $gsSurface;
647 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 + }
648 1192 } else {
649 - // The published pair. A lone global-styles fragment is dropped
650 - // rather than paired with a guess.
1193 + // The published pair.
651 1194 $surface = self::value(Arr::get($map, 'surface', ''));
652 1195 $text = self::value(Arr::get($map, 'text', ''));
653 1196
654 1197 if ($text === '' && ColorMath::hex($surface) !== '') {
@@ -659,16 +1202,27 @@
659 1202 $text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448');
660 1203 }
661 1204 }
662 1205
663 - $accent = self::value(Arr::get($map, 'accent', ''));
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', '');
664 1209
665 - // The button follows the same law as the page pair: what the owner
666 - // set in the site editor (Styles → Buttons) outranks every guess the
667 - // palette could offer — but only led by its background. A background
668 - // brings its own text partner, or gets one measured against it in
669 - // resolve(); a lone text fragment is dropped rather than printed onto
670 - // a background it was never chosen for.
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.
671 1225 $button = self::buttonGlobals();
672 1226 $buttonText = '';
673 1227
674 1228 if ($button['background'] !== '') {
@@ -673,8 +1227,12 @@
673 1227
674 1228 if ($button['background'] !== '') {
675 1229 $buttonBg = $button['background'];
676 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', '');
677 1235 } else {
678 1236 $buttonBg = self::value(Arr::get($map, 'button_bg', ''));
679 1237
680 1238 if ($buttonBg === '') {
@@ -681,8 +1239,10 @@
681 1239 // Not a default — the theme's own accent, which is what a
682 1240 // theme that names one button colour almost always means by it.
683 1241 $buttonBg = $accent;
684 1242 }
1243 +
1244 + $buttonText = $button['text'] !== '' ? $button['text'] : (string)Arr::get($settings, 'button_text', '');
685 1245 }
686 1246
687 1247 return [
688 1248 'surface' => $surface,
@@ -734,9 +1294,12 @@
734 1294 'surface_mute' => ColorMath::mix($textHex, $surfaceHex, 8),
735 1295 'divider' => ColorMath::mix($textHex, $surfaceHex, 10),
736 1296 'border' => ColorMath::mix($textHex, $surfaceHex, 18),
737 1297 'text_placeholder' => ColorMath::mix($textHex, $surfaceHex, 45),
738 - 'text_muted' => ColorMath::mix($textHex, $surfaceHex, 68),
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),
739 1302 ]
740 1303 : [
741 1304 'surface_alt' => '',
742 1305 'surface_mute' => '',
@@ -745,26 +1308,80 @@
745 1308 'text_placeholder' => '',
746 1309 'text_muted' => '',
747 1310 ];
748 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 +
749 1320 // Contrast needs a real colour to measure against for the same reason.
750 - // And the button is owned as a pair or not at all: a background whose
751 - // value cannot be measured (a bare reference — the theme resolves it on
752 - // the page, and the store owner can recolour it to anything) is one no
753 - // readable text can be paired with here, so neither half is written and
754 - // the stylesheets' own paired fallback styles the button instead. Text
755 - // the owner chose alongside the background (the site editor's button
756 - // pair) is worn as given; only a missing partner is measured.
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.
757 1328 $buttonBgHex = self::measurable($buttonBg);
758 1329 $ownText = (string)Arr::get($anchors, 'button_text', '');
1330 + $stated = self::buttonGlobals();
1331 + $buttonStated = $stated['background'] !== '' || Arr::get($settings, 'button_bg', '') !== '';
759 1332
760 1333 if ($buttonBgHex !== '') {
761 - $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableOn($buttonBgHex);
1334 + $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableText($buttonBgHex);
762 1335 } else {
763 - $anchors['button_bg'] = '';
764 - $derived['button_text'] = '';
1336 + if (!$buttonStated) {
1337 + $anchors['button_bg'] = '';
1338 + }
1339 +
1340 + $derived['button_text'] = $ownText;
765 1341 }
766 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 +
767 1384 // The outline secondary button is FluentCart's own invention, and its
768 1385 // outline is the hierarchy: it always keeps the page surface as its
769 1386 // background. Wearing the theme's stated pair outright painted BOTH
770 1387 // CTAs identically (Twenty Twenty-Five states black-on-white, so Add
@@ -799,8 +1416,24 @@
799 1416 */
800 1417 self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived));
801 1418
802 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);
803 1436 }
804 1437
805 1438 /**
806 1439 * A one-line description of what the active theme offers, shown under the