| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Services\Theme; |
| 4 |
|
| 5 |
use FluentCart\App\Services\Theme\Readers\AstraSettingsReader; |
| 6 |
use FluentCart\App\Services\Theme\Readers\BlocksySettingsReader; |
| 7 |
use FluentCart\App\Services\Theme\Readers\BricksSettingsReader; |
| 8 |
use FluentCart\App\Services\Theme\Readers\DiviSettingsReader; |
| 9 |
use FluentCart\App\Services\Theme\Readers\GeneratePressSettingsReader; |
| 10 |
use FluentCart\App\Services\Theme\Readers\KadenceSettingsReader; |
| 11 |
use FluentCart\Framework\Support\Arr; |
| 12 |
|
| 13 |
/** |
| 14 |
* Reads the active theme's colour palette and resolves it into the semantic |
| 15 |
* roles the storefront needs. |
| 16 |
* |
| 17 |
* A block theme publishes a handful of palette entries in theme.json. The |
| 18 |
* storefront needs a surface, a body text tone, an accent and a button colour |
| 19 |
* as anchors, plus the in-between tones — borders, dividers, muted text, |
| 20 |
* placeholder text — that no theme bothers to declare. Those are derived from |
| 21 |
* the anchors rather than invented, so the store follows the theme instead of |
| 22 |
* merely sitting next to it, and keeps following when the palette changes. |
| 23 |
*/ |
| 24 |
class ThemePalette |
| 25 |
{ |
| 26 |
/** |
| 27 |
* Palette slugs tried for each anchor role, most specific first. |
| 28 |
* |
| 29 |
* These are the slugs themes agree on — `base`/`contrast` come from Twenty |
| 30 |
* Twenty-Four and the themes that copied it, `primary`/`accent` from the |
| 31 |
* classic-adjacent ones, and GeneratePress publishes the same names as |
| 32 |
* references its own stylesheets resolve (measured through its Global |
| 33 |
* Colors; see vendorValues()). |
| 34 |
* |
| 35 |
* Three vendors are supported by name — the ones popular enough to be worth |
| 36 |
* carrying, each mapping written down from the theme's own sources rather |
| 37 |
* than inferred from the numbering: |
| 38 |
* |
| 39 |
* - Astra numbers 0 brand, 3 body text, 4 background. |
| 40 |
* - Kadence's theme.json names `theme-palette1` "Accent"; its defaults set |
| 41 |
* the body font to `palette4` and the content background to `palette9`. |
| 42 |
* - Blocksy paints its buttons with `palette-color-1`, defaults Base Text |
| 43 |
* to `palette-color-3`, and its surfaces inherit `palette-color-8`. |
| 44 |
* |
| 45 |
* All publish their palette as `var()` references the theme itself |
| 46 |
* declares on every page, so the reference is written through and the |
| 47 |
* browser resolves it there. Blocksy's references carry hex fallbacks, |
| 48 |
* which makes them measurable as shipped; Astra's and Kadence's are bare, |
| 49 |
* so their current values are read from the vendor's own settings and |
| 50 |
* attached as the fallback (see vendorValues()) — the same measurable |
| 51 |
* shape, arrived at from the source the vendor prints the property from. |
| 52 |
* Either way derived tones and button contrast resolve for real. Should a |
| 53 |
* reference still measure as nothing, the button is not owned at all (see |
| 54 |
* resolve()) — never a guessed text colour on an unknown background. |
| 55 |
* |
| 56 |
* The vendor slugs sit last so the shared names always win first — a real |
| 57 |
* colour can also be mixed and measured, a reference can only be written. Every other vendor still takes the stand-aside path |
| 58 |
* (nothing written, the no-colors marker on the body, the theme's own rules |
| 59 |
* styling the CTAs), or the `fluent_cart/theme/anchor_map` filter. |
| 60 |
* |
| 61 |
* @var array |
| 62 |
*/ |
| 63 |
protected static $anchorCandidates = [ |
| 64 |
'surface' => ['base', 'background', 'white', 'base-2', 'light', 'ast-global-color-4', 'theme-palette9', 'palette-color-8'], |
| 65 |
'text' => ['contrast', 'text', 'foreground', 'black', 'dark', 'contrast-2', 'ast-global-color-3', 'theme-palette4', 'palette-color-3'], |
| 66 |
'accent' => ['primary', 'accent', 'brand', 'accent-1', 'theme-1', 'link', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], |
| 67 |
'button_bg' => ['primary', 'accent', 'brand', 'contrast', 'accent-1', 'theme-1', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], |
| 68 |
]; |
| 69 |
|
| 70 |
/** |
| 71 |
* Request-level cache for the normalised palette. |
| 72 |
* |
| 73 |
* @var array|null |
| 74 |
*/ |
| 75 |
protected static $cachedPalette = null; |
| 76 |
|
| 77 |
/** |
| 78 |
* Request-level cache for the resolved roles. |
| 79 |
* |
| 80 |
* @var array|null |
| 81 |
*/ |
| 82 |
protected static $cachedRoles = null; |
| 83 |
|
| 84 |
/** |
| 85 |
* Request-level cache for the vendor-declared property values. |
| 86 |
* |
| 87 |
* @var array|null |
| 88 |
*/ |
| 89 |
protected static $cachedVendorValues = null; |
| 90 |
|
| 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 |
/** |
| 134 |
* Drop the request-level caches. |
| 135 |
* |
| 136 |
* Reading theme.json and resolving eleven roles is repeated work within a |
| 137 |
* request, so both are cached. Anything that changes what those reads would |
| 138 |
* return — switching theme, editing the palette, a filter registered after |
| 139 |
* a first read — has to clear them or it keeps seeing the old palette. |
| 140 |
* |
| 141 |
* @return void |
| 142 |
*/ |
| 143 |
public static function clearCache(): void |
| 144 |
{ |
| 145 |
self::$cachedPalette = null; |
| 146 |
self::$cachedRoles = null; |
| 147 |
self::$cachedVendorValues = null; |
| 148 |
self::$cachedSettingsRoles = null; |
| 149 |
} |
| 150 |
|
| 151 |
/** |
| 152 |
* The active theme's colour palette, normalised to hex. |
| 153 |
* |
| 154 |
* @return array List of ['slug' => ..., 'name' => ..., 'color' => ...]. |
| 155 |
*/ |
| 156 |
public static function palette(): array |
| 157 |
{ |
| 158 |
if (self::$cachedPalette !== null) { |
| 159 |
return self::$cachedPalette; |
| 160 |
} |
| 161 |
|
| 162 |
if (!function_exists('wp_get_global_settings')) { |
| 163 |
self::$cachedPalette = []; |
| 164 |
|
| 165 |
return self::$cachedPalette; |
| 166 |
} |
| 167 |
|
| 168 |
$raw = wp_get_global_settings(['color', 'palette']); |
| 169 |
|
| 170 |
self::$cachedPalette = is_array($raw) ? self::normalizePalette($raw) : []; |
| 171 |
|
| 172 |
return self::$cachedPalette; |
| 173 |
} |
| 174 |
|
| 175 |
/** |
| 176 |
* Flatten whatever shape wp_get_global_settings() returned. |
| 177 |
* |
| 178 |
* Depending on the WordPress version this is either a flat list or one |
| 179 |
* list per origin. The `default` origin is WordPress's own twelve-colour |
| 180 |
* palette — black, white and the vivid-* set — which every site has |
| 181 |
* whether or not its theme declares anything. Inheriting from it would |
| 182 |
* not be inheriting from the theme, and it would let a theme that |
| 183 |
* publishes nothing report a palette it does not have, so only the |
| 184 |
* theme's own colours and the user's customisations of them are read. |
| 185 |
* |
| 186 |
* @param array $raw |
| 187 |
* @return array |
| 188 |
*/ |
| 189 |
protected static function normalizePalette(array $raw): array |
| 190 |
{ |
| 191 |
$groups = []; |
| 192 |
|
| 193 |
if (isset($raw['default']) || isset($raw['theme']) || isset($raw['custom'])) { |
| 194 |
// `custom` comes last so a colour edited in the site editor wins |
| 195 |
// over the theme's declared value for the same slug. |
| 196 |
foreach (['theme', 'custom'] as $origin) { |
| 197 |
$originColors = Arr::get($raw, $origin, []); |
| 198 |
|
| 199 |
if (is_array($originColors) && $originColors) { |
| 200 |
$groups[] = $originColors; |
| 201 |
} |
| 202 |
} |
| 203 |
} else { |
| 204 |
$groups[] = $raw; |
| 205 |
} |
| 206 |
|
| 207 |
$entries = []; |
| 208 |
|
| 209 |
foreach ($groups as $colors) { |
| 210 |
foreach ($colors as $color) { |
| 211 |
$slug = Arr::get($color, 'slug', ''); |
| 212 |
|
| 213 |
if (!$slug) { |
| 214 |
continue; |
| 215 |
} |
| 216 |
|
| 217 |
$entries[$slug] = [ |
| 218 |
'slug' => (string)$slug, |
| 219 |
'name' => (string)Arr::get($color, 'name', $slug), |
| 220 |
'color' => (string)Arr::get($color, 'color', ''), |
| 221 |
]; |
| 222 |
} |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* Filter the theme palette offered for colour inheritance. |
| 227 |
* |
| 228 |
* @param array $entries List of ['slug', 'name', 'color']. |
| 229 |
*/ |
| 230 |
$entries = apply_filters('fluent_cart/theme/palette', array_values($entries)); |
| 231 |
|
| 232 |
return self::usableEntries($entries); |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* Keep only the entries that name a slug and a colour we can actually read. |
| 237 |
* |
| 238 |
* theme.json is not restricted to hex, and several popular themes publish |
| 239 |
* their palette as `var(--theme-colour-0)` references that cannot be |
| 240 |
* resolved server-side. Those are dropped rather than guessed: an |
| 241 |
* unresolvable value handed on as if it were a colour would be mixed into |
| 242 |
* the derived tones, and a muted text colour mixed from nothing collapses |
| 243 |
* to the surface it sits on — invisible text rather than an obvious error. |
| 244 |
* |
| 245 |
* Applied after the filter as well as before it, because a filter is just |
| 246 |
* another source of values and gets no more trust than theme.json does. |
| 247 |
* |
| 248 |
* @param mixed $entries |
| 249 |
* @return array |
| 250 |
*/ |
| 251 |
protected static function usableEntries($entries): array |
| 252 |
{ |
| 253 |
if (!is_array($entries)) { |
| 254 |
return []; |
| 255 |
} |
| 256 |
|
| 257 |
$usable = []; |
| 258 |
|
| 259 |
foreach ($entries as $entry) { |
| 260 |
if (!is_array($entry)) { |
| 261 |
continue; |
| 262 |
} |
| 263 |
|
| 264 |
$slug = Arr::get($entry, 'slug', ''); |
| 265 |
$raw = (string)Arr::get($entry, 'color', ''); |
| 266 |
$hex = ColorMath::hex($raw); |
| 267 |
|
| 268 |
// A literal colour is preferred because it can also be mixed and |
| 269 |
// measured. A reference cannot be resolved here, but the browser |
| 270 |
// resolves it perfectly well, so it is kept as something we can |
| 271 |
// write — and when it carries a hex fallback, that fallback is the |
| 272 |
// colour it can be measured as too. |
| 273 |
$value = $hex !== '' ? $hex : self::safeReference($raw); |
| 274 |
|
| 275 |
// A bare reference to a property the active vendor itself declares |
| 276 |
// is only unreadable to an outsider: the vendor prints it from its |
| 277 |
// own settings, and those are one function call away. Attaching |
| 278 |
// that value as the fallback turns the reference into the |
| 279 |
// measurable shape — the browser still follows the live property, |
| 280 |
// the fallback is only what it is measured as here. |
| 281 |
if ($hex === '' && $value !== '' && self::measurable($value) === '') { |
| 282 |
$property = substr($value, 4, -1); |
| 283 |
$vendorHex = (string)Arr::get(self::vendorValues(), $property, ''); |
| 284 |
|
| 285 |
if ($vendorHex !== '') { |
| 286 |
$value = 'var(' . $property . ', ' . $vendorHex . ')'; |
| 287 |
} |
| 288 |
} |
| 289 |
|
| 290 |
if (!$slug || $value === '') { |
| 291 |
continue; |
| 292 |
} |
| 293 |
|
| 294 |
$usable[(string)$slug] = [ |
| 295 |
'slug' => (string)$slug, |
| 296 |
'name' => (string)Arr::get($entry, 'name', $slug), |
| 297 |
'color' => self::measurable($value), |
| 298 |
'value' => $value, |
| 299 |
]; |
| 300 |
} |
| 301 |
|
| 302 |
return array_values($usable); |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* The current values of the properties the active vendor declares, keyed |
| 307 |
* by property name. |
| 308 |
* |
| 309 |
* Each mapping reads the same source the vendor prints the property from, |
| 310 |
* so customisations are included — the customiser saves into the very |
| 311 |
* option being read. Astra prints `--ast-global-color-N` from |
| 312 |
* `astra_get_option('global-color-palette')` (its |
| 313 |
* `generate_global_palette_style()`), and Kadence prints |
| 314 |
* `--global-paletteN` from `kadence()->palette_option('paletteN')` (its |
| 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()). |
| 326 |
* |
| 327 |
* The values are only ever used as the fallback half of `var(--x, #hex)`, |
| 328 |
* so a wrong or stale answer cannot repaint anything — the browser keeps |
| 329 |
* resolving the live property — it can only mis-measure, which is where |
| 330 |
* resolve() refusing an unmeasurable button still protects the store. |
| 331 |
* |
| 332 |
* @return array Property => hex. |
| 333 |
*/ |
| 334 |
protected static function vendorValues(): array |
| 335 |
{ |
| 336 |
if (self::$cachedVendorValues !== null) { |
| 337 |
return self::$cachedVendorValues; |
| 338 |
} |
| 339 |
|
| 340 |
$values = []; |
| 341 |
|
| 342 |
if (function_exists('astra_get_option')) { |
| 343 |
$palette = astra_get_option('global-color-palette'); |
| 344 |
$colors = is_array($palette) ? Arr::get($palette, 'palette', []) : []; |
| 345 |
|
| 346 |
foreach ((array)$colors as $index => $color) { |
| 347 |
$values['--ast-global-color-' . $index] = (string)$color; |
| 348 |
} |
| 349 |
} |
| 350 |
|
| 351 |
foreach (BlocksySettingsReader::paletteValues() as $property => $color) { |
| 352 |
$values[$property] = $color; |
| 353 |
} |
| 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 |
|
| 367 |
/** |
| 368 |
* Filter the vendor-declared custom property values used to measure |
| 369 |
* bare palette references, keyed by property name (`--x` => `#hex`). |
| 370 |
* |
| 371 |
* @param array $values Property => colour. |
| 372 |
*/ |
| 373 |
$values = apply_filters('fluent_cart/theme/vendor_values', $values); |
| 374 |
|
| 375 |
// A filter is just another source of values: only a real property |
| 376 |
// name paired with a real hex survives. Kadence's palette10 can be an |
| 377 |
// oklch() expression, which this drops too — an expression cannot be |
| 378 |
// measured, and it must never ride into a declaration as a fallback. |
| 379 |
$clean = []; |
| 380 |
|
| 381 |
if (is_array($values)) { |
| 382 |
foreach ($values as $property => $color) { |
| 383 |
$colorHex = ColorMath::hex((string)$color); |
| 384 |
|
| 385 |
if ($colorHex !== '' && preg_match('/^--[A-Za-z0-9_-]+$/', (string)$property)) { |
| 386 |
$clean[(string)$property] = $colorHex; |
| 387 |
} |
| 388 |
} |
| 389 |
} |
| 390 |
|
| 391 |
return self::$cachedVendorValues = $clean; |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Accept a bare custom-property reference, and nothing more. |
| 396 |
* |
| 397 |
* Themes built on the page-builder stack — Astra, Kadence, GeneratePress — |
| 398 |
* publish their palette as `var(--ast-global-color-0)` rather than as a |
| 399 |
* literal, because the real value lives in their own settings and is |
| 400 |
* emitted as a custom property at runtime. Refusing those makes theme |
| 401 |
* inheritance do nothing on a large share of real stores. |
| 402 |
* |
| 403 |
* Only the single-argument form is accepted: no fallback expression, no |
| 404 |
* nesting, no parentheses beyond the one pair. This string is written into |
| 405 |
* a declaration on every storefront page, so the grammar is kept narrow |
| 406 |
* enough that nothing else can ride along inside it. |
| 407 |
* |
| 408 |
* @param string $value |
| 409 |
* @return string The normalised reference, or '' when it is not one. |
| 410 |
*/ |
| 411 |
protected static function safeReference($value): string |
| 412 |
{ |
| 413 |
$value = trim((string)$value); |
| 414 |
|
| 415 |
if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*\)$/', $value, $matches)) { |
| 416 |
return 'var(' . $matches[1] . ')'; |
| 417 |
} |
| 418 |
|
| 419 |
// One fallback shape is allowed, and only one: a hex colour. Customify |
| 420 |
// publishes its whole palette as `var(--customify-primary, #0e7c7b)` — |
| 421 |
// the reference follows the customiser live, and the fallback is the |
| 422 |
// one kind of fallback that is itself checkable. `red`, expressions and |
| 423 |
// nested var() stay refused. |
| 424 |
if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { |
| 425 |
return 'var(' . $matches[1] . ', ' . strtolower($matches[2]) . ')'; |
| 426 |
} |
| 427 |
|
| 428 |
return ''; |
| 429 |
} |
| 430 |
|
| 431 |
/** |
| 432 |
* The colour a value can actually be measured as. |
| 433 |
* |
| 434 |
* A hex is itself. A reference with a hex fallback is measured as the |
| 435 |
* fallback — the theme shipped it as the value to use when the property is |
| 436 |
* missing, which makes it the theme's own best answer for what the colour |
| 437 |
* is. A bare reference measures as nothing. |
| 438 |
* |
| 439 |
* @param string $value |
| 440 |
* @return string Hex, or ''. |
| 441 |
*/ |
| 442 |
public static function measurable($value): string |
| 443 |
{ |
| 444 |
$value = (string)$value; |
| 445 |
$hex = ColorMath::hex($value); |
| 446 |
|
| 447 |
if ($hex !== '') { |
| 448 |
return $hex; |
| 449 |
} |
| 450 |
|
| 451 |
if (preg_match('/^var\(\s*--[A-Za-z0-9_-]+\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { |
| 452 |
return strtolower($matches[1]); |
| 453 |
} |
| 454 |
|
| 455 |
return ''; |
| 456 |
} |
| 457 |
|
| 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 |
/** |
| 566 |
* Whether the active theme gave us anything usable. |
| 567 |
* |
| 568 |
* @return bool |
| 569 |
*/ |
| 570 |
public static function available(): bool |
| 571 |
{ |
| 572 |
return count(self::palette()) > 0; |
| 573 |
} |
| 574 |
|
| 575 |
/** |
| 576 |
* Whether the theme told us anything at all to inherit from. |
| 577 |
* |
| 578 |
* A theme can publish a palette we cannot read — several popular ones |
| 579 |
* declare theirs as `var(--theme-colour-0)` references — and it can set a |
| 580 |
* 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. |
| 583 |
* Only when all of them come back empty is there genuinely nothing to |
| 584 |
* inherit, and in that case inheriting must stay out of the way rather |
| 585 |
* than write FluentCart's own colours back to the page and call them the |
| 586 |
* theme's. |
| 587 |
* |
| 588 |
* @return bool |
| 589 |
*/ |
| 590 |
public static function hasUsableSource(): bool |
| 591 |
{ |
| 592 |
if (self::available()) { |
| 593 |
return true; |
| 594 |
} |
| 595 |
|
| 596 |
$globals = self::globalColors(); |
| 597 |
|
| 598 |
if (Arr::get($globals, 'background', '') !== '' || Arr::get($globals, 'text', '') !== '') { |
| 599 |
return true; |
| 600 |
} |
| 601 |
|
| 602 |
if (self::settingsRoles()) { |
| 603 |
return true; |
| 604 |
} |
| 605 |
|
| 606 |
return Arr::get(self::buttonGlobals(), 'background', '') !== ''; |
| 607 |
} |
| 608 |
|
| 609 |
/** |
| 610 |
* Look up one palette colour by slug. |
| 611 |
* |
| 612 |
* @param string $slug |
| 613 |
* @return string Hex, or '' when the slug is unknown. |
| 614 |
*/ |
| 615 |
public static function color(string $slug): string |
| 616 |
{ |
| 617 |
return self::lookup($slug, 'color'); |
| 618 |
} |
| 619 |
|
| 620 |
/** |
| 621 |
* What one palette slug resolves to for writing into CSS. |
| 622 |
* |
| 623 |
* Unlike color(), this can be a custom-property reference — use it when |
| 624 |
* emitting a declaration, and color() when the value has to be reasoned |
| 625 |
* about. |
| 626 |
* |
| 627 |
* @param string $slug |
| 628 |
* @return string Hex, a var() reference, or '' when the slug is unknown. |
| 629 |
*/ |
| 630 |
public static function value(string $slug): string |
| 631 |
{ |
| 632 |
return self::lookup($slug, 'value'); |
| 633 |
} |
| 634 |
|
| 635 |
/** |
| 636 |
* Read one field off a palette entry. |
| 637 |
* |
| 638 |
* @param string $slug |
| 639 |
* @param string $field |
| 640 |
* @return string |
| 641 |
*/ |
| 642 |
protected static function lookup(string $slug, string $field): string |
| 643 |
{ |
| 644 |
if ($slug === '') { |
| 645 |
return ''; |
| 646 |
} |
| 647 |
|
| 648 |
foreach (self::palette() as $entry) { |
| 649 |
if (Arr::get($entry, 'slug') === $slug) { |
| 650 |
return (string)Arr::get($entry, $field, ''); |
| 651 |
} |
| 652 |
} |
| 653 |
|
| 654 |
return ''; |
| 655 |
} |
| 656 |
|
| 657 |
/** |
| 658 |
* The theme's global background and text colours, when it sets them. |
| 659 |
* |
| 660 |
* @return array ['background' => hex, 'text' => hex]; either may be ''. |
| 661 |
*/ |
| 662 |
public static function globalColors(): array |
| 663 |
{ |
| 664 |
$colors = ['background' => '', 'text' => '']; |
| 665 |
|
| 666 |
if (!function_exists('wp_get_global_styles')) { |
| 667 |
return $colors; |
| 668 |
} |
| 669 |
|
| 670 |
$styles = wp_get_global_styles(['color']); |
| 671 |
|
| 672 |
if (!is_array($styles)) { |
| 673 |
return $colors; |
| 674 |
} |
| 675 |
|
| 676 |
$colors['background'] = self::resolveReference(Arr::get($styles, 'background', '')); |
| 677 |
$colors['text'] = self::resolveReference(Arr::get($styles, 'text', '')); |
| 678 |
|
| 679 |
return $colors; |
| 680 |
} |
| 681 |
|
| 682 |
/** |
| 683 |
* The button pair the theme declares and the site editor edits. |
| 684 |
* |
| 685 |
* Styles → Buttons in the site editor saves to |
| 686 |
* `styles.elements.button.color`, and block themes ship their own pair |
| 687 |
* there in theme.json — the most explicit statement either can make |
| 688 |
* about the buttons. The two origins merge per property, the owner's |
| 689 |
* edits over the theme's, which is exactly how WordPress itself paints |
| 690 |
* the button. Core's origin is left out on purpose: WordPress ships a |
| 691 |
* default button colour (#32373c) for every site, and counting that as |
| 692 |
* "the theme said something" would mean no theme could ever stand aside. |
| 693 |
* Backgrounds usually arrive as a preset reference |
| 694 |
* (`var:preset|color|contrast`), which resolves through the palette the |
| 695 |
* same way the page pair's do. |
| 696 |
* |
| 697 |
* The `:hover` pair (`styles.elements.button.:hover.color`) is read the |
| 698 |
* same way, into `hover_background` / `hover_text`. |
| 699 |
* |
| 700 |
* @return array ['background' => hex, 'text' => hex, 'hover_background' => hex, |
| 701 |
* 'hover_text' => hex]; any may be ''. |
| 702 |
*/ |
| 703 |
public static function buttonGlobals(): array |
| 704 |
{ |
| 705 |
$colors = ['background' => '', 'text' => '', 'hover_background' => '', 'hover_text' => '']; |
| 706 |
|
| 707 |
if (!class_exists('WP_Theme_JSON_Resolver')) { |
| 708 |
return $colors; |
| 709 |
} |
| 710 |
|
| 711 |
foreach (['get_theme_data', 'get_user_data'] as $origin) { |
| 712 |
if (!method_exists('WP_Theme_JSON_Resolver', $origin)) { |
| 713 |
continue; |
| 714 |
} |
| 715 |
|
| 716 |
$data = \WP_Theme_JSON_Resolver::$origin(); |
| 717 |
|
| 718 |
if (!is_object($data) || !method_exists($data, 'get_raw_data')) { |
| 719 |
continue; |
| 720 |
} |
| 721 |
|
| 722 |
$raw = (array)$data->get_raw_data(); |
| 723 |
$pairs = [ |
| 724 |
'' => Arr::get($raw, 'styles.elements.button.color', []), |
| 725 |
'hover_' => Arr::get($raw, 'styles.elements.button.:hover.color', []), |
| 726 |
]; |
| 727 |
|
| 728 |
foreach ($pairs as $prefix => $pair) { |
| 729 |
foreach (['background', 'text'] as $half) { |
| 730 |
$value = self::resolveReference((string)Arr::get((array)$pair, $half, '')); |
| 731 |
|
| 732 |
if ($value !== '') { |
| 733 |
$colors[$prefix . $half] = $value; |
| 734 |
} |
| 735 |
} |
| 736 |
} |
| 737 |
} |
| 738 |
|
| 739 |
return $colors; |
| 740 |
} |
| 741 |
|
| 742 |
/** |
| 743 |
* Resolve a theme.json colour, which may be a preset reference rather |
| 744 |
* than a literal colour. |
| 745 |
* |
| 746 |
* @param string $value |
| 747 |
* @return string Hex, or ''. |
| 748 |
*/ |
| 749 |
protected static function resolveReference($value): string |
| 750 |
{ |
| 751 |
$value = trim((string)$value); |
| 752 |
|
| 753 |
if ($value === '') { |
| 754 |
return ''; |
| 755 |
} |
| 756 |
|
| 757 |
if (preg_match('/var:preset\|color\|([\w-]+)/', $value, $matches)) { |
| 758 |
return self::color($matches[1]); |
| 759 |
} |
| 760 |
|
| 761 |
if (preg_match('/var\(\s*--wp--preset--color--([\w-]+)/', $value, $matches)) { |
| 762 |
return self::color($matches[1]); |
| 763 |
} |
| 764 |
|
| 765 |
return ColorMath::hex($value); |
| 766 |
} |
| 767 |
|
| 768 |
/** |
| 769 |
* Which palette slug each anchor role resolves to. |
| 770 |
* |
| 771 |
* @return array Role => palette slug, '' when nothing matched. |
| 772 |
*/ |
| 773 |
public static function anchorMap(): array |
| 774 |
{ |
| 775 |
$map = []; |
| 776 |
|
| 777 |
foreach (self::$anchorCandidates as $role => $slugs) { |
| 778 |
$map[$role] = ''; |
| 779 |
|
| 780 |
foreach ($slugs as $slug) { |
| 781 |
// value(), not color(): a slug the theme publishes only as a |
| 782 |
// reference still counts as a colour the theme offers. |
| 783 |
if (self::value($slug) !== '') { |
| 784 |
$map[$role] = $slug; |
| 785 |
break; |
| 786 |
} |
| 787 |
} |
| 788 |
} |
| 789 |
|
| 790 |
/** |
| 791 |
* Filter which theme palette slug drives each anchor role. |
| 792 |
* |
| 793 |
* @param array $map Role => palette slug. |
| 794 |
*/ |
| 795 |
return apply_filters('fluent_cart/theme/anchor_map', $map); |
| 796 |
} |
| 797 |
|
| 798 |
/** |
| 799 |
* The four anchors, exactly as far as the theme could be read. |
| 800 |
* |
| 801 |
* An anchor the theme did not supply comes back empty rather than standing |
| 802 |
* in FluentCart's own colour for it. The stylesheet that uses the property |
| 803 |
* already carries that colour as its `var(--fct-x, <fallback>)` fallback, |
| 804 |
* so substituting it here would only mean writing the same value twice — |
| 805 |
* and writing it under the active theme's name, which is how a store on a |
| 806 |
* theme nothing could be read from came to report itself as inheriting |
| 807 |
* while wearing FluentCart's palette. |
| 808 |
* |
| 809 |
* @return array Role => hex, a custom-property reference, or '' when the |
| 810 |
* theme said nothing about it. |
| 811 |
*/ |
| 812 |
public static function anchors(): array |
| 813 |
{ |
| 814 |
$map = self::anchorMap(); |
| 815 |
$globals = self::globalColors(); |
| 816 |
$settings = self::settingsRoles(); |
| 817 |
|
| 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. |
| 827 |
$gsSurface = (string)Arr::get($globals, 'background', ''); |
| 828 |
$gsText = (string)Arr::get($globals, 'text', ''); |
| 829 |
|
| 830 |
$setSurface = (string)Arr::get($settings, 'surface', ''); |
| 831 |
|
| 832 |
if ($gsSurface !== '' && $gsText !== '') { |
| 833 |
// The rendered pair. |
| 834 |
$surface = $gsSurface; |
| 835 |
$text = $gsText; |
| 836 |
} elseif ($setSurface !== '') { |
| 837 |
// 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. |
| 843 |
$surface = $setSurface; |
| 844 |
$text = (string)Arr::get($settings, 'text', ''); |
| 845 |
$surfaceHex = self::measurable($surface); |
| 846 |
|
| 847 |
if ($text === '' && $surfaceHex !== '') { |
| 848 |
$paletteText = self::value(Arr::get($map, 'text', '')); |
| 849 |
$paletteTextHex = self::measurable($paletteText); |
| 850 |
|
| 851 |
$text = $paletteTextHex !== '' && ColorMath::contrast($surfaceHex, $paletteTextHex) >= 4.5 |
| 852 |
? $paletteText |
| 853 |
: ColorMath::readableOn($surfaceHex, '#F3F4F6', '#2F3448'); |
| 854 |
} |
| 855 |
} else { |
| 856 |
// The published pair. A lone global-styles fragment is dropped |
| 857 |
// rather than paired with a guess. |
| 858 |
$surface = self::value(Arr::get($map, 'surface', '')); |
| 859 |
$text = self::value(Arr::get($map, 'text', '')); |
| 860 |
|
| 861 |
if ($text === '' && ColorMath::hex($surface) !== '') { |
| 862 |
// A lone measurable surface derives its partner by contrast, |
| 863 |
// the same way button text does — leaving it unwritten would |
| 864 |
// drop the stylesheets' dark fallback text onto a surface |
| 865 |
// that may itself be dark. |
| 866 |
$text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448'); |
| 867 |
} |
| 868 |
} |
| 869 |
|
| 870 |
$accent = (string)Arr::get($settings, 'accent', ''); |
| 871 |
|
| 872 |
if ($accent === '') { |
| 873 |
$accent = self::value(Arr::get($map, 'accent', '')); |
| 874 |
} |
| 875 |
|
| 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. |
| 882 |
$button = self::buttonGlobals(); |
| 883 |
$buttonText = ''; |
| 884 |
|
| 885 |
if ($button['background'] !== '') { |
| 886 |
$buttonBg = $button['background']; |
| 887 |
$buttonText = $button['text']; |
| 888 |
} elseif (Arr::get($settings, 'button_bg', '') !== '') { |
| 889 |
// Next, the button the theme's settings state, with its partner. |
| 890 |
$buttonBg = (string)$settings['button_bg']; |
| 891 |
$buttonText = (string)Arr::get($settings, 'button_text', ''); |
| 892 |
} else { |
| 893 |
$buttonBg = self::value(Arr::get($map, 'button_bg', '')); |
| 894 |
|
| 895 |
if ($buttonBg === '') { |
| 896 |
// Not a default — the theme's own accent, which is what a |
| 897 |
// theme that names one button colour almost always means by it. |
| 898 |
$buttonBg = $accent; |
| 899 |
} |
| 900 |
} |
| 901 |
|
| 902 |
return [ |
| 903 |
'surface' => $surface, |
| 904 |
'text' => $text, |
| 905 |
'accent' => $accent, |
| 906 |
'button_bg' => $buttonBg, |
| 907 |
'button_text' => $buttonText, |
| 908 |
]; |
| 909 |
} |
| 910 |
|
| 911 |
/** |
| 912 |
* Resolve every semantic role the theme can actually supply. |
| 913 |
* |
| 914 |
* The first block is what the theme said. The second is derived from it — |
| 915 |
* no theme publishes a hairline border or a placeholder tone, so those are |
| 916 |
* mixed out of the body text and the surface. |
| 917 |
* |
| 918 |
* Deriving needs two real colours to mix between. A reference cannot be |
| 919 |
* read here (the browser resolves it on the page, where we are not), and an |
| 920 |
* anchor the theme never supplied is not a colour at all, so in both cases |
| 921 |
* the derived roles come back empty and nothing is written for them. The |
| 922 |
* stylesheet's own fallback is then what applies, which is the same colour |
| 923 |
* it would have been given — arrived at once instead of twice. |
| 924 |
* |
| 925 |
* @return array Role => hex, a reference, or '' when it could not be |
| 926 |
* resolved. |
| 927 |
*/ |
| 928 |
public static function resolve(): array |
| 929 |
{ |
| 930 |
if (self::$cachedRoles !== null) { |
| 931 |
return self::$cachedRoles; |
| 932 |
} |
| 933 |
|
| 934 |
$anchors = self::anchors(); |
| 935 |
$surface = (string)Arr::get($anchors, 'surface', ''); |
| 936 |
$text = (string)Arr::get($anchors, 'text', ''); |
| 937 |
$buttonBg = (string)Arr::get($anchors, 'button_bg', ''); |
| 938 |
|
| 939 |
// Measured, not taken literally: a reference with a hex fallback mixes |
| 940 |
// as the fallback the theme shipped, so a Customify-style palette |
| 941 |
// derives instead of standing down. |
| 942 |
$textHex = self::measurable($text); |
| 943 |
$surfaceHex = self::measurable($surface); |
| 944 |
$mixable = $textHex !== '' && $surfaceHex !== ''; |
| 945 |
|
| 946 |
$derived = $mixable |
| 947 |
? [ |
| 948 |
'surface_alt' => ColorMath::mix($textHex, $surfaceHex, 4), |
| 949 |
'surface_mute' => ColorMath::mix($textHex, $surfaceHex, 8), |
| 950 |
'divider' => ColorMath::mix($textHex, $surfaceHex, 10), |
| 951 |
'border' => ColorMath::mix($textHex, $surfaceHex, 18), |
| 952 |
'text_placeholder' => ColorMath::mix($textHex, $surfaceHex, 45), |
| 953 |
// Muted text is still text: it moves toward the body text only |
| 954 |
// as far as it must to read (4.5:1). A mid-grey body text |
| 955 |
// (Divi's #666666) otherwise mixed down to 2.92:1. |
| 956 |
'text_muted' => ColorMath::readableMix($textHex, $surfaceHex, 68), |
| 957 |
] |
| 958 |
: [ |
| 959 |
'surface_alt' => '', |
| 960 |
'surface_mute' => '', |
| 961 |
'divider' => '', |
| 962 |
'border' => '', |
| 963 |
'text_placeholder' => '', |
| 964 |
'text_muted' => '', |
| 965 |
]; |
| 966 |
|
| 967 |
// A border the theme's settings state is the border (Astra's Borders); |
| 968 |
// only an unstated one is mixed. |
| 969 |
$settings = self::settingsRoles(); |
| 970 |
|
| 971 |
if (Arr::get($settings, 'border', '') !== '') { |
| 972 |
$derived['border'] = (string)$settings['border']; |
| 973 |
} |
| 974 |
|
| 975 |
// 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. |
| 984 |
$buttonBgHex = self::measurable($buttonBg); |
| 985 |
$ownText = self::statedTextOn((string)Arr::get($anchors, 'button_text', ''), $buttonBgHex); |
| 986 |
|
| 987 |
if ($buttonBgHex !== '') { |
| 988 |
$derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableText($buttonBgHex); |
| 989 |
} else { |
| 990 |
$anchors['button_bg'] = ''; |
| 991 |
$derived['button_text'] = ''; |
| 992 |
} |
| 993 |
|
| 994 |
// The hover follows the button it belongs to: unwritten when the button |
| 995 |
// is. The site editor's `:hover` pair outranks any guess; otherwise the |
| 996 |
// 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. |
| 1000 |
$derived['button_hover_bg'] = ''; |
| 1001 |
$derived['button_hover_text'] = ''; |
| 1002 |
|
| 1003 |
if ($buttonBgHex !== '') { |
| 1004 |
$stated = self::buttonGlobals(); |
| 1005 |
$hoverBg = $stated['hover_background']; |
| 1006 |
$hoverText = $stated['hover_text']; |
| 1007 |
|
| 1008 |
// The theme settings' hover belongs to the theme settings' button: |
| 1009 |
// it is only used when that button is the one being worn, never |
| 1010 |
// paired onto a button the site editor states. |
| 1011 |
$settingsButton = $stated['background'] === '' && Arr::get($settings, 'button_bg', '') !== ''; |
| 1012 |
|
| 1013 |
if ($hoverBg === '' && $settingsButton && Arr::get($settings, 'button_hover_bg', '') !== '') { |
| 1014 |
$hoverBg = (string)$settings['button_hover_bg']; |
| 1015 |
|
| 1016 |
if ($hoverText === '') { |
| 1017 |
$hoverText = (string)Arr::get($settings, 'button_hover_text', ''); |
| 1018 |
} |
| 1019 |
} |
| 1020 |
|
| 1021 |
if ($hoverBg === '') { |
| 1022 |
$hoverBg = ColorMath::shiftFromItself($buttonBgHex, 12); |
| 1023 |
} |
| 1024 |
|
| 1025 |
$hoverBgHex = self::measurable($hoverBg); |
| 1026 |
$hoverText = self::statedTextOn($hoverText, $hoverBgHex); |
| 1027 |
|
| 1028 |
if ($hoverText === '' && $hoverBgHex !== '') { |
| 1029 |
$hoverText = self::hoverTextFor($hoverBgHex, (string)$derived['button_text']); |
| 1030 |
} |
| 1031 |
|
| 1032 |
$derived['button_hover_bg'] = $hoverBg; |
| 1033 |
$derived['button_hover_text'] = $hoverText; |
| 1034 |
} |
| 1035 |
|
| 1036 |
// The outline secondary button is FluentCart's own invention, and its |
| 1037 |
// outline is the hierarchy: it always keeps the page surface as its |
| 1038 |
// background. Wearing the theme's stated pair outright painted BOTH |
| 1039 |
// CTAs identically (Twenty Twenty-Five states black-on-white, so Add |
| 1040 |
// to Cart went black beside a black Buy Now). Instead the label |
| 1041 |
// borrows a half of the pair AS WORN by the primary button (stated |
| 1042 |
// text, or the measured partner of a lone stated background) — |
| 1043 |
// whichever half reads better on the page surface, and only when |
| 1044 |
// that half actually reads (WCAG 4.5:1); otherwise the page's own |
| 1045 |
// text stays, since a borrowed label that cannot be read matches |
| 1046 |
// nothing worth matching. Measurement uses the surface's hex, which |
| 1047 |
// for a reference is its fallback — the theme's own best answer, the |
| 1048 |
// same trust every derived tone already extends (spec 59). |
| 1049 |
$derived['secondary_button_bg'] = $surface; |
| 1050 |
$derived['secondary_button_text'] = $text; |
| 1051 |
|
| 1052 |
if (self::buttonGlobals()['background'] !== '' && $buttonBgHex !== '' && $surfaceHex !== '') { |
| 1053 |
$pairText = (string)$derived['button_text']; |
| 1054 |
|
| 1055 |
$label = ColorMath::contrast($surfaceHex, $pairText) >= ColorMath::contrast($surfaceHex, $buttonBgHex) |
| 1056 |
? $pairText |
| 1057 |
: $buttonBgHex; |
| 1058 |
|
| 1059 |
if (ColorMath::contrast($surfaceHex, $label) >= 4.5) { |
| 1060 |
$derived['secondary_button_text'] = $label; |
| 1061 |
} |
| 1062 |
} |
| 1063 |
|
| 1064 |
/** |
| 1065 |
* Filter the resolved semantic role colours. |
| 1066 |
* |
| 1067 |
* @param array $roles Role => hex. |
| 1068 |
*/ |
| 1069 |
self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived)); |
| 1070 |
|
| 1071 |
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 |
} |
| 1102 |
|
| 1103 |
/** |
| 1104 |
* The text for a hover background nobody gave a text: the button's |
| 1105 |
* resting text carries over while it still reads (WCAG 4.5:1), and a |
| 1106 |
* partner is measured when it does not. |
| 1107 |
* |
| 1108 |
* @param string $hoverBgHex |
| 1109 |
* @param string $restingText |
| 1110 |
* @return string |
| 1111 |
*/ |
| 1112 |
public static function hoverTextFor(string $hoverBgHex, string $restingText): string |
| 1113 |
{ |
| 1114 |
return ColorMath::contrast($hoverBgHex, $restingText) >= 4.5 |
| 1115 |
? $restingText |
| 1116 |
: ColorMath::readableText($hoverBgHex); |
| 1117 |
} |
| 1118 |
|
| 1119 |
/** |
| 1120 |
* A one-line description of what the active theme offers, shown under the |
| 1121 |
* inherit option so the store owner knows whether it is worth picking. |
| 1122 |
* |
| 1123 |
* @return string |
| 1124 |
*/ |
| 1125 |
public static function sourceLabel(): string |
| 1126 |
{ |
| 1127 |
$count = count(self::palette()); |
| 1128 |
|
| 1129 |
if (!$count) { |
| 1130 |
return __('The active theme does not publish a colour palette, so inheriting would fall back to FluentCart\'s own colours.', 'fluent-cart'); |
| 1131 |
} |
| 1132 |
|
| 1133 |
$theme = wp_get_theme(); |
| 1134 |
|
| 1135 |
return sprintf( |
| 1136 |
/* translators: 1: active theme name, 2: number of palette colours the theme publishes */ |
| 1137 |
_n('%1$s provides %2$d palette colour.', '%1$s provides %2$d palette colours.', $count, 'fluent-cart'), |
| 1138 |
$theme->get('Name'), |
| 1139 |
$count |
| 1140 |
); |
| 1141 |
} |
| 1142 |
} |
| 1143 |
|