PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
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 +346 -51 1.6.5 → 1.7.0 View file →
@@ -1,8 +1,14 @@
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;
5 11 use FluentCart\Framework\Support\Arr;
6 12
7 13 /**
8 14 * Reads the active theme's colour palette and resolves it into the semantic
@@ -22,9 +28,10 @@
22 28 *
23 29 * These are the slugs themes agree on — `base`/`contrast` come from Twenty
24 30 * Twenty-Four and the themes that copied it, `primary`/`accent` from the
25 31 * classic-adjacent ones, and GeneratePress publishes the same names as
26 - * references its own stylesheets resolve.
32 + * references its own stylesheets resolve (measured through its Global
33 + * Colors; see vendorValues()).
27 34 *
28 35 * Three vendors are supported by name — the ones popular enough to be worth
29 36 * carrying, each mapping written down from the theme's own sources rather
30 37 * than inferred from the numbering:
@@ -81,8 +88,50 @@
81 88 */
82 89 protected static $cachedVendorValues = null;
83 90
84 91 /**
92 + * Request-level cache for the roles the theme's own settings state.
93 + *
94 + * @var array|null
95 + */
96 + protected static $cachedSettingsRoles = null;
97 +
98 + /**
99 + * Theme settings readers, keyed by the theme name passed to the
100 + * `fluent_cart/theme/settings_roles` filter. The first that applies wins.
101 + *
102 + * Only one theme runs on a site, so the order only matters when several
103 + * vendors' functions are loaded at once (the test stubs). The stricter
104 + * checks go first: Blocksy's and Kadence's each need a live instance from
105 + * the vendor's API, Divi's needs the `$shortname` global to name Divi, and
106 + * Bricks' needs its running `Bricks\Theme` singleton, and GeneratePress's
107 + * needs its dynamic-CSS printer hooked on `wp_enqueue_scripts`, none of
108 + * which a loaded-but-idle stub provides, while Astra's is a bare
109 + * `function_exists()` that stays true once its stub is loaded — so Astra
110 + * is asked last. Among the strict ones the order is arbitrary.
111 + *
112 + * @var array
113 + */
114 + protected static $settingsReaders = [
115 + 'blocksy' => BlocksySettingsReader::class,
116 + 'kadence' => KadenceSettingsReader::class,
117 + 'divi' => DiviSettingsReader::class,
118 + 'bricks' => BricksSettingsReader::class,
119 + 'generatepress' => GeneratePressSettingsReader::class,
120 + 'astra' => AstraSettingsReader::class,
121 + ];
122 +
123 + /**
124 + * The roles a theme settings reader may state.
125 + *
126 + * @var array
127 + */
128 + protected static $settingsRoleKeys = [
129 + 'surface', 'text', 'accent', 'border',
130 + 'button_bg', 'button_text', 'button_hover_bg', 'button_hover_text',
131 + ];
132 +
133 + /**
85 134 * Drop the request-level caches.
86 135 *
87 136 * Reading theme.json and resolving eleven roles is repeated work within a
88 137 * request, so both are cached. Anything that changes what those reads would
@@ -95,8 +144,9 @@
95 144 {
96 145 self::$cachedPalette = null;
97 146 self::$cachedRoles = null;
98 147 self::$cachedVendorValues = null;
148 + self::$cachedSettingsRoles = null;
99 149 }
100 150
101 151 /**
102 152 * The active theme's colour palette, normalised to hex.
@@ -261,10 +311,19 @@
261 311 * option being read. Astra prints `--ast-global-color-N` from
262 312 * `astra_get_option('global-color-palette')` (its
263 313 * `generate_global_palette_style()`), and Kadence prints
264 314 * `--global-paletteN` from `kadence()->palette_option('paletteN')` (its
265 - * styles component). Blocksy needs no entry: its palette already ships
266 - * with hex fallbacks.
315 + * styles component; see KadenceSettingsReader::paletteValues()).
316 + * Blocksy's editor palette already ships with hex fallbacks, but its Customizer settings point at the bare
317 + * `--theme-palette-color-N`, so its palette is read too, from
318 + * `blocksy_manager()->colors->get_color_palette()` (the list it prints
319 + * the properties from; see BlocksySettingsReader::paletteValues()).
320 + * Bricks prints `--bricks-color-{id}` from its colour palette (or the
321 + * property a palette colour's raw value names; see
322 + * BricksSettingsReader::paletteValues()). GeneratePress prints `--{slug}`
323 + * on :root from its Global Colors and publishes them to the editor
324 + * palette as bare `var(--{slug})` (see
325 + * GeneratePressSettingsReader::paletteValues()).
267 326 *
268 327 * The values are only ever used as the fallback half of `var(--x, #hex)`,
269 328 * so a wrong or stale answer cannot repaint anything — the browser keeps
270 329 * resolving the live property — it can only mis-measure, which is where
@@ -288,19 +347,24 @@
288 347 $values['--ast-global-color-' . $index] = (string)$color;
289 348 }
290 349 }
291 350
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 - }
351 + foreach (BlocksySettingsReader::paletteValues() as $property => $color) {
352 + $values[$property] = $color;
301 353 }
302 354
355 + foreach (KadenceSettingsReader::paletteValues() as $property => $color) {
356 + $values[$property] = $color;
357 + }
358 +
359 + foreach (BricksSettingsReader::paletteValues() as $property => $color) {
360 + $values[$property] = $color;
361 + }
362 +
363 + foreach (GeneratePressSettingsReader::paletteValues() as $property => $color) {
364 + $values[$property] = $color;
365 + }
366 +
303 367 /**
304 368 * Filter the vendor-declared custom property values used to measure
305 369 * bare palette references, keyed by property name (`--x` => `#hex`).
306 370 *
@@ -391,8 +455,115 @@
391 455 return '';
392 456 }
393 457
394 458 /**
459 + * Normalise a colour a theme setting states into a value we can write.
460 + *
461 + * A hex is itself, lowercased. A custom-property reference stays a live
462 + * reference; a bare one gains the vendor's current value as its hex
463 + * fallback (see vendorValues()), so the browser still follows the
464 + * property while PHP measures the fallback. Anything else — rgba(),
465 + * named colours, expressions — is refused: the result is written into a
466 + * declaration on every storefront page and must pass
467 + * FrontendTheme::sanitizeDeclarationValue().
468 + *
469 + * @param mixed $raw
470 + * @return string Hex, `var(--x)`, `var(--x, #hex)`, or ''.
471 + */
472 + public static function settingValue($raw): string
473 + {
474 + if (!is_string($raw)) {
475 + return '';
476 + }
477 +
478 + $raw = trim($raw);
479 +
480 + if (preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $raw)) {
481 + return ColorMath::hex($raw);
482 + }
483 +
484 + $reference = self::safeReference($raw);
485 +
486 + if ($reference === '' || self::measurable($reference) !== '') {
487 + return $reference;
488 + }
489 +
490 + $property = substr($reference, 4, -1);
491 + $vendorHex = (string)Arr::get(self::vendorValues(), $property, '');
492 +
493 + return $vendorHex !== '' ? 'var(' . $property . ', ' . $vendorHex . ')' : $reference;
494 + }
495 +
496 + /**
497 + * The colours the active theme's own settings state, by role.
498 + *
499 + * Palette slots say which colours a theme offers; its settings say which
500 + * one the owner put where. A reader for the active theme (see
501 + * Readers\ThemeSettingsReader) reports what the owner set — Astra's,
502 + * Blocksy's, Kadence's, Divi's, Bricks' or GeneratePress's Accent, Body Text, Content Background, Borders
503 + * and Button colours — and those outrank the palette-slot guesses in anchors() and resolve().
504 + * Only the site editor's own button (buttonGlobals()) outranks them.
505 + *
506 + * Roles: surface, text, accent, border, button_bg, button_text,
507 + * button_hover_bg, button_hover_text. A role the theme does not state is
508 + * absent, and is derived exactly as it would be without a reader.
509 + *
510 + * @return array Role => hex or reference.
511 + */
512 + public static function settingsRoles(): array
513 + {
514 + if (self::$cachedSettingsRoles !== null) {
515 + return self::$cachedSettingsRoles;
516 + }
517 +
518 + $roles = [];
519 + $theme = '';
520 +
521 + foreach (self::$settingsReaders as $name => $reader) {
522 + if ($reader::applies()) {
523 + $theme = $name;
524 + $roles = $reader::roles();
525 + break;
526 + }
527 + }
528 +
529 + /**
530 + * Filter the colours the active theme's settings state, by role.
531 + *
532 + * Runs whether or not a built-in reader applied, so a theme FluentCart
533 + * does not read can be supplied here; return [] to turn the reader off
534 + * and fall back to palette slots. Values must be a hex or a
535 + * `var(--x)` / `var(--x, #hex)` reference — anything else is dropped,
536 + * and a bare reference to a vendor property gains its hex fallback.
537 + *
538 + * @param array $roles Role => colour. Keys: surface, text, accent,
539 + * border, button_bg, button_text,
540 + * button_hover_bg, button_hover_text.
541 + * @param array $context ['theme' => reader name ('blocksy', 'kadence', 'divi', 'bricks', 'generatepress', 'astra'), or '' when
542 + * no built-in reader applies].
543 + */
544 + $roles = apply_filters('fluent_cart/theme/settings_roles', $roles, [
545 + 'theme' => $theme,
546 + ]);
547 +
548 + // A filter is just another source of values: only known roles with a
549 + // writable colour survive.
550 + $clean = [];
551 +
552 + if (is_array($roles)) {
553 + foreach (self::$settingsRoleKeys as $role) {
554 + $value = self::settingValue(Arr::get($roles, $role, ''));
555 +
556 + if ($value !== '') {
557 + $clean[$role] = $value;
558 + }
559 + }
560 + }
561 +
562 + return self::$cachedSettingsRoles = $clean;
563 + }
564 +
565 + /**
395 566 * Whether the active theme gave us anything usable.
396 567 *
397 568 * @return bool
398 569 */
@@ -406,10 +577,11 @@
406 577 *
407 578 * A theme can publish a palette we cannot read — several popular ones
408 579 * declare theirs as `var(--theme-colour-0)` references — and it can set a
409 580 * 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.
581 + * can also state nothing but its button colours (Styles → Buttons in the
582 + * site editor) — a background, a text, or both — and that is still an
583 + * explicit configuration to wear.
412 584 * Only when all of them come back empty is there genuinely nothing to
413 585 * inherit, and in that case inheriting must stay out of the way rather
414 586 * than write FluentCart's own colours back to the page and call them the
415 587 * theme's.
@@ -427,9 +599,15 @@
427 599 if (Arr::get($globals, 'background', '') !== '' || Arr::get($globals, 'text', '') !== '') {
428 600 return true;
429 601 }
430 602
431 - return Arr::get(self::buttonGlobals(), 'background', '') !== '';
603 + if (self::settingsRoles()) {
604 + return true;
605 + }
606 +
607 + $button = self::buttonGlobals();
608 +
609 + return Arr::get($button, 'background', '') !== '' || Arr::get($button, 'text', '') !== '';
432 610 }
433 611
434 612 /**
435 613 * Look up one palette colour by slug.
@@ -518,13 +696,17 @@
518 696 * Backgrounds usually arrive as a preset reference
519 697 * (`var:preset|color|contrast`), which resolves through the palette the
520 698 * same way the page pair's do.
521 699 *
522 - * @return array ['background' => hex, 'text' => hex]; either may be ''.
700 + * The `:hover` pair (`styles.elements.button.:hover.color`) is read the
701 + * same way, into `hover_background` / `hover_text`.
702 + *
703 + * @return array ['background' => hex, 'text' => hex, 'hover_background' => hex,
704 + * 'hover_text' => hex]; any may be ''.
523 705 */
524 706 public static function buttonGlobals(): array
525 707 {
526 - $colors = ['background' => '', 'text' => ''];
708 + $colors = ['background' => '', 'text' => '', 'hover_background' => '', 'hover_text' => ''];
527 709
528 710 if (!class_exists('WP_Theme_JSON_Resolver')) {
529 711 return $colors;
530 712 }
@@ -539,15 +721,21 @@
539 721 if (!is_object($data) || !method_exists($data, 'get_raw_data')) {
540 722 continue;
541 723 }
542 724
543 - $pair = Arr::get((array)$data->get_raw_data(), 'styles.elements.button.color', []);
725 + $raw = (array)$data->get_raw_data();
726 + $pairs = [
727 + '' => Arr::get($raw, 'styles.elements.button.color', []),
728 + 'hover_' => Arr::get($raw, 'styles.elements.button.:hover.color', []),
729 + ];
544 730
545 - foreach (['background', 'text'] as $half) {
546 - $value = self::resolveReference((string)Arr::get((array)$pair, $half, ''));
731 + foreach ($pairs as $prefix => $pair) {
732 + foreach (['background', 'text'] as $half) {
733 + $value = self::resolveReference((string)Arr::get((array)$pair, $half, ''));
547 734
548 - if ($value !== '') {
549 - $colors[$half] = $value;
735 + if ($value !== '') {
736 + $colors[$prefix . $half] = $value;
737 + }
550 738 }
551 739 }
552 740 }
553 741
@@ -627,28 +815,45 @@
627 815 public static function anchors(): array
628 816 {
629 817 $map = self::anchorMap();
630 818 $globals = self::globalColors();
819 + $settings = self::settingsRoles();
631 820
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.
821 + // Global styles outrank the palette — the palette lists the AVAILABLE
822 + // colours, global styles say what the body actually RENDERS, and on
823 + // Twenty Twenty-Five's dark style variation those disagree. The
824 + // surface comes from global styles, then the theme's settings, then
825 + // the palette. A body text the owner stated is worn as given below,
826 + // whichever source the surface came from.
641 827 $gsSurface = (string)Arr::get($globals, 'background', '');
642 828 $gsText = (string)Arr::get($globals, 'text', '');
643 829
830 + $setSurface = (string)Arr::get($settings, 'surface', '');
831 +
644 832 if ($gsSurface !== '' && $gsText !== '') {
645 833 // The rendered pair.
646 834 $surface = $gsSurface;
647 835 $text = $gsText;
836 + } elseif ($setSurface !== '') {
837 + // The pair the theme's settings state (Astra's Content Background
838 + // and Body Text). A surface stated without a text takes the
839 + // palette's own text when it reads there (4.5:1) — Astra saves
840 + // Body Text empty and still means its text swatch — and only an
841 + // unreadable one is replaced by a measured partner.
842 + $surface = $setSurface;
843 + $text = (string)Arr::get($settings, 'text', '');
844 + $surfaceHex = self::measurable($surface);
845 +
846 + if ($text === '' && $surfaceHex !== '') {
847 + $paletteText = self::value(Arr::get($map, 'text', ''));
848 + $paletteTextHex = self::measurable($paletteText);
849 +
850 + $text = $paletteTextHex !== '' && ColorMath::contrast($surfaceHex, $paletteTextHex) >= 4.5
851 + ? $paletteText
852 + : ColorMath::readableOn($surfaceHex, '#F3F4F6', '#2F3448');
853 + }
648 854 } else {
649 - // The published pair. A lone global-styles fragment is dropped
650 - // rather than paired with a guess.
855 + // The published pair.
651 856 $surface = self::value(Arr::get($map, 'surface', ''));
652 857 $text = self::value(Arr::get($map, 'text', ''));
653 858
654 859 if ($text === '' && ColorMath::hex($surface) !== '') {
@@ -659,16 +864,27 @@
659 864 $text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448');
660 865 }
661 866 }
662 867
663 - $accent = self::value(Arr::get($map, 'accent', ''));
868 + // A body text the owner chose is worn as given, even without a stated
869 + // background — global styles first, then the theme's settings.
870 + $statedText = $gsText !== '' ? $gsText : (string)Arr::get($settings, 'text', '');
664 871
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.
872 + if ($statedText !== '') {
873 + $text = $statedText;
874 + }
875 +
876 + $accent = (string)Arr::get($settings, 'accent', '');
877 +
878 + if ($accent === '') {
879 + $accent = self::value(Arr::get($map, 'accent', ''));
880 + }
881 +
882 + // What the owner set in the site editor (Styles → Buttons) outranks
883 + // every guess the palette could offer, then the theme's settings. A
884 + // background brings its own text, or gets one measured against it in
885 + // resolve(); a text the owner set alone is worn as given on whatever
886 + // background the button otherwise gets.
671 887 $button = self::buttonGlobals();
672 888 $buttonText = '';
673 889
674 890 if ($button['background'] !== '') {
@@ -673,8 +889,12 @@
673 889
674 890 if ($button['background'] !== '') {
675 891 $buttonBg = $button['background'];
676 892 $buttonText = $button['text'];
893 + } elseif (Arr::get($settings, 'button_bg', '') !== '') {
894 + // Next, the button the theme's settings state, with its partner.
895 + $buttonBg = (string)$settings['button_bg'];
896 + $buttonText = (string)Arr::get($settings, 'button_text', '');
677 897 } else {
678 898 $buttonBg = self::value(Arr::get($map, 'button_bg', ''));
679 899
680 900 if ($buttonBg === '') {
@@ -681,8 +901,10 @@
681 901 // Not a default — the theme's own accent, which is what a
682 902 // theme that names one button colour almost always means by it.
683 903 $buttonBg = $accent;
684 904 }
905 +
906 + $buttonText = $button['text'] !== '' ? $button['text'] : (string)Arr::get($settings, 'button_text', '');
685 907 }
686 908
687 909 return [
688 910 'surface' => $surface,
@@ -734,9 +956,12 @@
734 956 'surface_mute' => ColorMath::mix($textHex, $surfaceHex, 8),
735 957 'divider' => ColorMath::mix($textHex, $surfaceHex, 10),
736 958 'border' => ColorMath::mix($textHex, $surfaceHex, 18),
737 959 'text_placeholder' => ColorMath::mix($textHex, $surfaceHex, 45),
738 - 'text_muted' => ColorMath::mix($textHex, $surfaceHex, 68),
960 + // Muted text is still text: it moves toward the body text only
961 + // as far as it must to read (4.5:1). A mid-grey body text
962 + // (Divi's #666666) otherwise mixed down to 2.92:1.
963 + 'text_muted' => ColorMath::readableMix($textHex, $surfaceHex, 68),
739 964 ]
740 965 : [
741 966 'surface_alt' => '',
742 967 'surface_mute' => '',
@@ -745,26 +970,80 @@
745 970 'text_placeholder' => '',
746 971 'text_muted' => '',
747 972 ];
748 973
974 + // A border the theme's settings state is the border (Astra's Borders);
975 + // only an unstated one is mixed.
976 + $settings = self::settingsRoles();
977 +
978 + if (Arr::get($settings, 'border', '') !== '') {
979 + $derived['border'] = (string)$settings['border'];
980 + }
981 +
749 982 // 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.
983 + // Colours the owner chose (the site editor's, or a theme settings
984 + // reader's) are worn exactly as given, at any contrast; only a missing
985 + // one is measured. A background nobody chose whose value cannot be
986 + // measured (a bare palette reference) is not worn at all: no readable
987 + // text can be paired with it here, so the stylesheets' own fallback
988 + // styles the button instead. One the owner chose is still worn, with
989 + // nothing invented beside it.
757 990 $buttonBgHex = self::measurable($buttonBg);
758 991 $ownText = (string)Arr::get($anchors, 'button_text', '');
992 + $stated = self::buttonGlobals();
993 + $buttonStated = $stated['background'] !== '' || Arr::get($settings, 'button_bg', '') !== '';
759 994
760 995 if ($buttonBgHex !== '') {
761 - $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableOn($buttonBgHex);
996 + $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableText($buttonBgHex);
762 997 } else {
763 - $anchors['button_bg'] = '';
764 - $derived['button_text'] = '';
998 + if (!$buttonStated) {
999 + $anchors['button_bg'] = '';
1000 + }
1001 +
1002 + $derived['button_text'] = $ownText;
765 1003 }
766 1004
1005 + // The hover follows the button it belongs to: unwritten when the button
1006 + // is. The site editor's `:hover` pair outranks any guess; otherwise the
1007 + // button colour moves away from itself — darker for a light button,
1008 + // lighter for a dark one, and never for a button that cannot be
1009 + // measured. Hover text given with it is worn as given; otherwise the
1010 + // resting text carries over while it still reads (WCAG 4.5:1), and a
1011 + // partner is measured when it does not.
1012 + $derived['button_hover_bg'] = '';
1013 + $derived['button_hover_text'] = '';
1014 +
1015 + if ($buttonBgHex !== '' || $buttonStated) {
1016 + $hoverBg = $stated['hover_background'];
1017 + $hoverText = $stated['hover_text'];
1018 +
1019 + // The theme settings' hover belongs to the theme settings' button:
1020 + // it is only used when that button is the one being worn, never
1021 + // paired onto a button the site editor states.
1022 + $settingsButton = $stated['background'] === '' && Arr::get($settings, 'button_bg', '') !== '';
1023 +
1024 + if ($hoverBg === '' && $settingsButton && Arr::get($settings, 'button_hover_bg', '') !== '') {
1025 + $hoverBg = (string)$settings['button_hover_bg'];
1026 +
1027 + if ($hoverText === '') {
1028 + $hoverText = (string)Arr::get($settings, 'button_hover_text', '');
1029 + }
1030 + }
1031 +
1032 + if ($hoverBg === '' && $buttonBgHex !== '') {
1033 + $hoverBg = ColorMath::shiftFromItself($buttonBgHex, 12);
1034 + }
1035 +
1036 + $hoverBgHex = self::measurable($hoverBg);
1037 +
1038 + if ($hoverText === '' && $hoverBgHex !== '') {
1039 + $hoverText = self::hoverTextFor($hoverBgHex, (string)$derived['button_text']);
1040 + }
1041 +
1042 + $derived['button_hover_bg'] = $hoverBg;
1043 + $derived['button_hover_text'] = $hoverText;
1044 + }
1045 +
767 1046 // The outline secondary button is FluentCart's own invention, and its
768 1047 // outline is the hierarchy: it always keeps the page surface as its
769 1048 // background. Wearing the theme's stated pair outright painted BOTH
770 1049 // CTAs identically (Twenty Twenty-Five states black-on-white, so Add
@@ -799,8 +1078,24 @@
799 1078 */
800 1079 self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived));
801 1080
802 1081 return self::$cachedRoles;
1082 + }
1083 +
1084 + /**
1085 + * The text for a hover background nobody gave a text: the button's
1086 + * resting text carries over while it still reads (WCAG 4.5:1), and a
1087 + * partner is measured when it does not.
1088 + *
1089 + * @param string $hoverBgHex
1090 + * @param string $restingText
1091 + * @return string
1092 + */
1093 + public static function hoverTextFor(string $hoverBgHex, string $restingText): string
1094 + {
1095 + return ColorMath::contrast($hoverBgHex, $restingText) >= 4.5
1096 + ? $restingText
1097 + : ColorMath::readableText($hoverBgHex);
803 1098 }
804 1099
805 1100 /**
806 1101 * A one-line description of what the active theme offers, shown under the