| @@ -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 |