| @@ -7,8 +7,9 @@ | ||
| 7 | 7 | use FluentCart\App\Services\Theme\Readers\BricksSettingsReader; |
| 8 | 8 | use FluentCart\App\Services\Theme\Readers\DiviSettingsReader; |
| 9 | 9 | use FluentCart\App\Services\Theme\Readers\GeneratePressSettingsReader; |
| 10 | 10 | use FluentCart\App\Services\Theme\Readers\KadenceSettingsReader; |
| 11 | +use FluentCart\App\Services\Theme\Readers\ThemeRadiusReader; | |
| 11 | 12 | use FluentCart\Framework\Support\Arr; |
| 12 | 13 | |
| 13 | 14 | /** |
| 14 | 15 | * Reads the active theme's colour palette and resolves it into the semantic |
| @@ -95,8 +96,22 @@ | ||
| 95 | 96 | */ |
| 96 | 97 | protected static $cachedSettingsRoles = null; |
| 97 | 98 | |
| 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 | + /** | |
| 99 | 114 | * Theme settings readers, keyed by the theme name passed to the |
| 100 | 115 | * `fluent_cart/theme/settings_roles` filter. The first that applies wins. |
| 101 | 116 | * |
| 102 | 117 | * Only one theme runs on a site, so the order only matters when several |
| @@ -145,8 +160,10 @@ | ||
| 145 | 160 | self::$cachedPalette = null; |
| 146 | 161 | self::$cachedRoles = null; |
| 147 | 162 | self::$cachedVendorValues = null; |
| 148 | 163 | self::$cachedSettingsRoles = null; |
| 164 | + self::$cachedRadii = null; | |
| 165 | + self::$settingsReaderOff = false; | |
| 149 | 166 | } |
| 150 | 167 | |
| 151 | 168 | /** |
| 152 | 169 | * The active theme's colour palette, normalised to hex. |
| @@ -525,8 +542,10 @@ | ||
| 525 | 542 | break; |
| 526 | 543 | } |
| 527 | 544 | } |
| 528 | 545 | |
| 546 | + $stated = $roles; | |
| 547 | + | |
| 529 | 548 | /** |
| 530 | 549 | * Filter the colours the active theme's settings state, by role. |
| 531 | 550 | * |
| 532 | 551 | * Runs whether or not a built-in reader applied, so a theme FluentCart |
| @@ -558,12 +577,331 @@ | ||
| 558 | 577 | } |
| 559 | 578 | } |
| 560 | 579 | } |
| 561 | 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 | + | |
| 562 | 585 | return self::$cachedSettingsRoles = $clean; |
| 563 | 586 | } |
| 564 | 587 | |
| 565 | 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 | + /** | |
| 566 | 904 | * Whether the active theme gave us anything usable. |
| 567 | 905 | * |
| 568 | 906 | * @return bool |
| 569 | 907 | */ |
| @@ -577,10 +915,11 @@ | ||
| 577 | 915 | * |
| 578 | 916 | * A theme can publish a palette we cannot read — several popular ones |
| 579 | 917 | * declare theirs as `var(--theme-colour-0)` references — and it can set a |
| 580 | 918 | * global background and text colour without publishing a palette. A site |
| 581 | - * can also state nothing but its button pair (Styles → Buttons in the | |
| 582 | - * 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. | |
| 583 | 922 | * Only when all of them come back empty is there genuinely nothing to |
| 584 | 923 | * inherit, and in that case inheriting must stay out of the way rather |
| 585 | 924 | * than write FluentCart's own colours back to the page and call them the |
| 586 | 925 | * theme's. |
| @@ -602,9 +941,11 @@ | ||
| 602 | 941 | if (self::settingsRoles()) { |
| 603 | 942 | return true; |
| 604 | 943 | } |
| 605 | 944 | |
| 606 | - return Arr::get(self::buttonGlobals(), 'background', '') !== ''; | |
| 945 | + $button = self::buttonGlobals(); | |
| 946 | + | |
| 947 | + return Arr::get($button, 'background', '') !== '' || Arr::get($button, 'text', '') !== ''; | |
| 607 | 948 | } |
| 608 | 949 | |
| 609 | 950 | /** |
| 610 | 951 | * Look up one palette colour by slug. |
| @@ -814,17 +1155,14 @@ | ||
| 814 | 1155 | $map = self::anchorMap(); |
| 815 | 1156 | $globals = self::globalColors(); |
| 816 | 1157 | $settings = self::settingsRoles(); |
| 817 | 1158 | |
| 818 | - // Surface and body text resolve as a PAIR from one source, never mixed | |
| 819 | - // from two. Global styles outrank the palette — the palette lists the | |
| 820 | - // AVAILABLE colours, global styles say what the body actually RENDERS, | |
| 821 | - // and on Twenty Twenty-Five's dark style variation those disagree — | |
| 822 | - // but only when global styles supply BOTH halves. A site that sets | |
| 823 | - // only its text (light, say, over a CSS-painted dark background) must | |
| 824 | - // not have that fragment mixed onto the palette's guessed white | |
| 825 | - // surface: that is light text printed onto a light panel, and the | |
| 826 | - // 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. | |
| 827 | 1165 | $gsSurface = (string)Arr::get($globals, 'background', ''); |
| 828 | 1166 | $gsText = (string)Arr::get($globals, 'text', ''); |
| 829 | 1167 | |
| 830 | 1168 | $setSurface = (string)Arr::get($settings, 'surface', ''); |
| @@ -834,13 +1172,12 @@ | ||
| 834 | 1172 | $surface = $gsSurface; |
| 835 | 1173 | $text = $gsText; |
| 836 | 1174 | } elseif ($setSurface !== '') { |
| 837 | 1175 | // The pair the theme's settings state (Astra's Content Background |
| 838 | - // and Body Text). The same pair law: a lone stated text is never | |
| 839 | - // mixed onto a guessed surface. A lone surface takes the palette's | |
| 840 | - // own text when it reads there (4.5:1) — Astra saves Body Text | |
| 841 | - // empty and still means its text swatch — and only an unreadable | |
| 842 | - // one is replaced by a measured partner. | |
| 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. | |
| 843 | 1180 | $surface = $setSurface; |
| 844 | 1181 | $text = (string)Arr::get($settings, 'text', ''); |
| 845 | 1182 | $surfaceHex = self::measurable($surface); |
| 846 | 1183 | |
| @@ -852,10 +1189,9 @@ | ||
| 852 | 1189 | ? $paletteText |
| 853 | 1190 | : ColorMath::readableOn($surfaceHex, '#F3F4F6', '#2F3448'); |
| 854 | 1191 | } |
| 855 | 1192 | } else { |
| 856 | - // The published pair. A lone global-styles fragment is dropped | |
| 857 | - // rather than paired with a guess. | |
| 1193 | + // The published pair. | |
| 858 | 1194 | $surface = self::value(Arr::get($map, 'surface', '')); |
| 859 | 1195 | $text = self::value(Arr::get($map, 'text', '')); |
| 860 | 1196 | |
| 861 | 1197 | if ($text === '' && ColorMath::hex($surface) !== '') { |
| @@ -866,8 +1202,16 @@ | ||
| 866 | 1202 | $text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448'); |
| 867 | 1203 | } |
| 868 | 1204 | } |
| 869 | 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 | + | |
| 870 | 1214 | $accent = (string)Arr::get($settings, 'accent', ''); |
| 871 | 1215 | |
| 872 | 1216 | if ($accent === '') { |
| 873 | 1217 | $accent = self::value(Arr::get($map, 'accent', '')); |
| @@ -872,14 +1216,13 @@ | ||
| 872 | 1216 | if ($accent === '') { |
| 873 | 1217 | $accent = self::value(Arr::get($map, 'accent', '')); |
| 874 | 1218 | } |
| 875 | 1219 | |
| 876 | - // The button follows the same law as the page pair: what the owner | |
| 877 | - // set in the site editor (Styles → Buttons) outranks every guess the | |
| 878 | - // palette could offer — but only led by its background. A background | |
| 879 | - // brings its own text partner, or gets one measured against it in | |
| 880 | - // resolve(); a lone text fragment is dropped rather than printed onto | |
| 881 | - // a background it was never chosen for. | |
| 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. | |
| 882 | 1225 | $button = self::buttonGlobals(); |
| 883 | 1226 | $buttonText = ''; |
| 884 | 1227 | |
| 885 | 1228 | if ($button['background'] !== '') { |
| @@ -896,8 +1239,10 @@ | ||
| 896 | 1239 | // Not a default — the theme's own accent, which is what a |
| 897 | 1240 | // theme that names one button colour almost always means by it. |
| 898 | 1241 | $buttonBg = $accent; |
| 899 | 1242 | } |
| 1243 | + | |
| 1244 | + $buttonText = $button['text'] !== '' ? $button['text'] : (string)Arr::get($settings, 'button_text', ''); | |
| 900 | 1245 | } |
| 901 | 1246 | |
| 902 | 1247 | return [ |
| 903 | 1248 | 'surface' => $surface, |
| @@ -972,37 +1317,41 @@ | ||
| 972 | 1317 | $derived['border'] = (string)$settings['border']; |
| 973 | 1318 | } |
| 974 | 1319 | |
| 975 | 1320 | // Contrast needs a real colour to measure against for the same reason. |
| 976 | - // And the button is owned as a pair or not at all: a background whose | |
| 977 | - // value cannot be measured (a bare reference — the theme resolves it on | |
| 978 | - // the page, and the store owner can recolour it to anything) is one no | |
| 979 | - // readable text can be paired with here, so neither half is written and | |
| 980 | - // the stylesheets' own paired fallback styles the button instead. Text | |
| 981 | - // the owner chose alongside the background (the site editor's button | |
| 982 | - // pair, or a theme settings reader's) is worn as given unless it cannot | |
| 983 | - // be read on it (below 3:1); a missing or unreadable 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. | |
| 984 | 1328 | $buttonBgHex = self::measurable($buttonBg); |
| 985 | - $ownText = self::statedTextOn((string)Arr::get($anchors, 'button_text', ''), $buttonBgHex); | |
| 1329 | + $ownText = (string)Arr::get($anchors, 'button_text', ''); | |
| 1330 | + $stated = self::buttonGlobals(); | |
| 1331 | + $buttonStated = $stated['background'] !== '' || Arr::get($settings, 'button_bg', '') !== ''; | |
| 986 | 1332 | |
| 987 | 1333 | if ($buttonBgHex !== '') { |
| 988 | 1334 | $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableText($buttonBgHex); |
| 989 | 1335 | } else { |
| 990 | - $anchors['button_bg'] = ''; | |
| 991 | - $derived['button_text'] = ''; | |
| 1336 | + if (!$buttonStated) { | |
| 1337 | + $anchors['button_bg'] = ''; | |
| 1338 | + } | |
| 1339 | + | |
| 1340 | + $derived['button_text'] = $ownText; | |
| 992 | 1341 | } |
| 993 | 1342 | |
| 994 | 1343 | // The hover follows the button it belongs to: unwritten when the button |
| 995 | 1344 | // is. The site editor's `:hover` pair outranks any guess; otherwise the |
| 996 | 1345 | // button colour moves away from itself — darker for a light button, |
| 997 | - // lighter for a dark one. Hover text given with it is worn as given; | |
| 998 | - // otherwise the resting text carries over while it still reads (WCAG | |
| 999 | - // 4.5:1), and a partner is measured when it does not. | |
| 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. | |
| 1000 | 1350 | $derived['button_hover_bg'] = ''; |
| 1001 | 1351 | $derived['button_hover_text'] = ''; |
| 1002 | 1352 | |
| 1003 | - if ($buttonBgHex !== '') { | |
| 1004 | - $stated = self::buttonGlobals(); | |
| 1353 | + if ($buttonBgHex !== '' || $buttonStated) { | |
| 1005 | 1354 | $hoverBg = $stated['hover_background']; |
| 1006 | 1355 | $hoverText = $stated['hover_text']; |
| 1007 | 1356 | |
| 1008 | 1357 | // The theme settings' hover belongs to the theme settings' button: |
| @@ -1017,14 +1366,13 @@ | ||
| 1017 | 1366 | $hoverText = (string)Arr::get($settings, 'button_hover_text', ''); |
| 1018 | 1367 | } |
| 1019 | 1368 | } |
| 1020 | 1369 | |
| 1021 | - if ($hoverBg === '') { | |
| 1370 | + if ($hoverBg === '' && $buttonBgHex !== '') { | |
| 1022 | 1371 | $hoverBg = ColorMath::shiftFromItself($buttonBgHex, 12); |
| 1023 | 1372 | } |
| 1024 | 1373 | |
| 1025 | 1374 | $hoverBgHex = self::measurable($hoverBg); |
| 1026 | - $hoverText = self::statedTextOn($hoverText, $hoverBgHex); | |
| 1027 | 1375 | |
| 1028 | 1376 | if ($hoverText === '' && $hoverBgHex !== '') { |
| 1029 | 1377 | $hoverText = self::hoverTextFor($hoverBgHex, (string)$derived['button_text']); |
| 1030 | 1378 | } |
| @@ -1068,37 +1416,8 @@ | ||
| 1068 | 1416 | */ |
| 1069 | 1417 | self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived)); |
| 1070 | 1418 | |
| 1071 | 1419 | return self::$cachedRoles; |
| 1072 | - } | |
| 1073 | - | |
| 1074 | - /** | |
| 1075 | - * A text colour a theme or owner stated for a background, if it can be worn. | |
| 1076 | - * | |
| 1077 | - * Stated text is a choice and is worn as given — including brand pairs | |
| 1078 | - * just under AA (white on #ff5500 is 3.21:1). Only one that cannot be read | |
| 1079 | - * on its background (below 3:1, the large-text floor) is refused, so a | |
| 1080 | - * measured partner takes its place. Twenty Twenty-Five's site-editor pair | |
| 1081 | - * #111111 on #503aa8 (2.26:1) is the case this catches. A text or a | |
| 1082 | - * background that cannot be measured here is not second-guessed. | |
| 1083 | - * | |
| 1084 | - * @param string $text The stated text, or ''. | |
| 1085 | - * @param string $backgroundHex The measured background, or ''. | |
| 1086 | - * @return string The text, or '' when it must be replaced. | |
| 1087 | - */ | |
| 1088 | - public static function statedTextOn(string $text, string $backgroundHex): string | |
| 1089 | - { | |
| 1090 | - if ($text === '' || $backgroundHex === '') { | |
| 1091 | - return $text; | |
| 1092 | - } | |
| 1093 | - | |
| 1094 | - $textHex = self::measurable($text); | |
| 1095 | - | |
| 1096 | - if ($textHex === '') { | |
| 1097 | - return $text; | |
| 1098 | - } | |
| 1099 | - | |
| 1100 | - return ColorMath::contrast($backgroundHex, $textHex) >= 3 ? $text : ''; | |
| 1101 | 1420 | } |
| 1102 | 1421 | |
| 1103 | 1422 | /** |
| 1104 | 1423 | * The text for a hover background nobody gave a text: the button's |