| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Services\Theme; |
| 4 |
|
| 5 |
use FluentCart\Framework\Support\Arr; |
| 6 |
|
| 7 |
/** |
| 8 |
* Reads the active theme's colour palette and resolves it into the semantic |
| 9 |
* roles the storefront needs. |
| 10 |
* |
| 11 |
* A block theme publishes a handful of palette entries in theme.json. The |
| 12 |
* storefront needs a surface, a body text tone, an accent and a button colour |
| 13 |
* as anchors, plus the in-between tones — borders, dividers, muted text, |
| 14 |
* placeholder text — that no theme bothers to declare. Those are derived from |
| 15 |
* the anchors rather than invented, so the store follows the theme instead of |
| 16 |
* merely sitting next to it, and keeps following when the palette changes. |
| 17 |
*/ |
| 18 |
class ThemePalette |
| 19 |
{ |
| 20 |
/** |
| 21 |
* Palette slugs tried for each anchor role, most specific first. |
| 22 |
* |
| 23 |
* These are the slugs themes agree on — `base`/`contrast` come from Twenty |
| 24 |
* Twenty-Four and the themes that copied it, `primary`/`accent` from the |
| 25 |
* classic-adjacent ones, and GeneratePress publishes the same names as |
| 26 |
* references its own stylesheets resolve. |
| 27 |
* |
| 28 |
* Three vendors are supported by name — the ones popular enough to be worth |
| 29 |
* carrying, each mapping written down from the theme's own sources rather |
| 30 |
* than inferred from the numbering: |
| 31 |
* |
| 32 |
* - Astra numbers 0 brand, 3 body text, 4 background. |
| 33 |
* - Kadence's theme.json names `theme-palette1` "Accent"; its defaults set |
| 34 |
* the body font to `palette4` and the content background to `palette9`. |
| 35 |
* - Blocksy paints its buttons with `palette-color-1`, defaults Base Text |
| 36 |
* to `palette-color-3`, and its surfaces inherit `palette-color-8`. |
| 37 |
* |
| 38 |
* All publish their palette as `var()` references the theme itself |
| 39 |
* declares on every page, so the reference is written through and the |
| 40 |
* browser resolves it there. Blocksy's references carry hex fallbacks, |
| 41 |
* which makes them measurable as shipped; Astra's and Kadence's are bare, |
| 42 |
* so their current values are read from the vendor's own settings and |
| 43 |
* attached as the fallback (see vendorValues()) — the same measurable |
| 44 |
* shape, arrived at from the source the vendor prints the property from. |
| 45 |
* Either way derived tones and button contrast resolve for real. Should a |
| 46 |
* reference still measure as nothing, the button is not owned at all (see |
| 47 |
* resolve()) — never a guessed text colour on an unknown background. |
| 48 |
* |
| 49 |
* The vendor slugs sit last so the shared names always win first — a real |
| 50 |
* colour can also be mixed and measured, a reference can only be written. Every other vendor still takes the stand-aside path |
| 51 |
* (nothing written, the no-colors marker on the body, the theme's own rules |
| 52 |
* styling the CTAs), or the `fluent_cart/theme/anchor_map` filter. |
| 53 |
* |
| 54 |
* @var array |
| 55 |
*/ |
| 56 |
protected static $anchorCandidates = [ |
| 57 |
'surface' => ['base', 'background', 'white', 'base-2', 'light', 'ast-global-color-4', 'theme-palette9', 'palette-color-8'], |
| 58 |
'text' => ['contrast', 'text', 'foreground', 'black', 'dark', 'contrast-2', 'ast-global-color-3', 'theme-palette4', 'palette-color-3'], |
| 59 |
'accent' => ['primary', 'accent', 'brand', 'accent-1', 'theme-1', 'link', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], |
| 60 |
'button_bg' => ['primary', 'accent', 'brand', 'contrast', 'accent-1', 'theme-1', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], |
| 61 |
]; |
| 62 |
|
| 63 |
/** |
| 64 |
* Request-level cache for the normalised palette. |
| 65 |
* |
| 66 |
* @var array|null |
| 67 |
*/ |
| 68 |
protected static $cachedPalette = null; |
| 69 |
|
| 70 |
/** |
| 71 |
* Request-level cache for the resolved roles. |
| 72 |
* |
| 73 |
* @var array|null |
| 74 |
*/ |
| 75 |
protected static $cachedRoles = null; |
| 76 |
|
| 77 |
/** |
| 78 |
* Request-level cache for the vendor-declared property values. |
| 79 |
* |
| 80 |
* @var array|null |
| 81 |
*/ |
| 82 |
protected static $cachedVendorValues = null; |
| 83 |
|
| 84 |
/** |
| 85 |
* Drop the request-level caches. |
| 86 |
* |
| 87 |
* Reading theme.json and resolving eleven roles is repeated work within a |
| 88 |
* request, so both are cached. Anything that changes what those reads would |
| 89 |
* return — switching theme, editing the palette, a filter registered after |
| 90 |
* a first read — has to clear them or it keeps seeing the old palette. |
| 91 |
* |
| 92 |
* @return void |
| 93 |
*/ |
| 94 |
public static function clearCache(): void |
| 95 |
{ |
| 96 |
self::$cachedPalette = null; |
| 97 |
self::$cachedRoles = null; |
| 98 |
self::$cachedVendorValues = null; |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* The active theme's colour palette, normalised to hex. |
| 103 |
* |
| 104 |
* @return array List of ['slug' => ..., 'name' => ..., 'color' => ...]. |
| 105 |
*/ |
| 106 |
public static function palette(): array |
| 107 |
{ |
| 108 |
if (self::$cachedPalette !== null) { |
| 109 |
return self::$cachedPalette; |
| 110 |
} |
| 111 |
|
| 112 |
if (!function_exists('wp_get_global_settings')) { |
| 113 |
self::$cachedPalette = []; |
| 114 |
|
| 115 |
return self::$cachedPalette; |
| 116 |
} |
| 117 |
|
| 118 |
$raw = wp_get_global_settings(['color', 'palette']); |
| 119 |
|
| 120 |
self::$cachedPalette = is_array($raw) ? self::normalizePalette($raw) : []; |
| 121 |
|
| 122 |
return self::$cachedPalette; |
| 123 |
} |
| 124 |
|
| 125 |
/** |
| 126 |
* Flatten whatever shape wp_get_global_settings() returned. |
| 127 |
* |
| 128 |
* Depending on the WordPress version this is either a flat list or one |
| 129 |
* list per origin. The `default` origin is WordPress's own twelve-colour |
| 130 |
* palette — black, white and the vivid-* set — which every site has |
| 131 |
* whether or not its theme declares anything. Inheriting from it would |
| 132 |
* not be inheriting from the theme, and it would let a theme that |
| 133 |
* publishes nothing report a palette it does not have, so only the |
| 134 |
* theme's own colours and the user's customisations of them are read. |
| 135 |
* |
| 136 |
* @param array $raw |
| 137 |
* @return array |
| 138 |
*/ |
| 139 |
protected static function normalizePalette(array $raw): array |
| 140 |
{ |
| 141 |
$groups = []; |
| 142 |
|
| 143 |
if (isset($raw['default']) || isset($raw['theme']) || isset($raw['custom'])) { |
| 144 |
// `custom` comes last so a colour edited in the site editor wins |
| 145 |
// over the theme's declared value for the same slug. |
| 146 |
foreach (['theme', 'custom'] as $origin) { |
| 147 |
$originColors = Arr::get($raw, $origin, []); |
| 148 |
|
| 149 |
if (is_array($originColors) && $originColors) { |
| 150 |
$groups[] = $originColors; |
| 151 |
} |
| 152 |
} |
| 153 |
} else { |
| 154 |
$groups[] = $raw; |
| 155 |
} |
| 156 |
|
| 157 |
$entries = []; |
| 158 |
|
| 159 |
foreach ($groups as $colors) { |
| 160 |
foreach ($colors as $color) { |
| 161 |
$slug = Arr::get($color, 'slug', ''); |
| 162 |
|
| 163 |
if (!$slug) { |
| 164 |
continue; |
| 165 |
} |
| 166 |
|
| 167 |
$entries[$slug] = [ |
| 168 |
'slug' => (string)$slug, |
| 169 |
'name' => (string)Arr::get($color, 'name', $slug), |
| 170 |
'color' => (string)Arr::get($color, 'color', ''), |
| 171 |
]; |
| 172 |
} |
| 173 |
} |
| 174 |
|
| 175 |
/** |
| 176 |
* Filter the theme palette offered for colour inheritance. |
| 177 |
* |
| 178 |
* @param array $entries List of ['slug', 'name', 'color']. |
| 179 |
*/ |
| 180 |
$entries = apply_filters('fluent_cart/theme/palette', array_values($entries)); |
| 181 |
|
| 182 |
return self::usableEntries($entries); |
| 183 |
} |
| 184 |
|
| 185 |
/** |
| 186 |
* Keep only the entries that name a slug and a colour we can actually read. |
| 187 |
* |
| 188 |
* theme.json is not restricted to hex, and several popular themes publish |
| 189 |
* their palette as `var(--theme-colour-0)` references that cannot be |
| 190 |
* resolved server-side. Those are dropped rather than guessed: an |
| 191 |
* unresolvable value handed on as if it were a colour would be mixed into |
| 192 |
* the derived tones, and a muted text colour mixed from nothing collapses |
| 193 |
* to the surface it sits on — invisible text rather than an obvious error. |
| 194 |
* |
| 195 |
* Applied after the filter as well as before it, because a filter is just |
| 196 |
* another source of values and gets no more trust than theme.json does. |
| 197 |
* |
| 198 |
* @param mixed $entries |
| 199 |
* @return array |
| 200 |
*/ |
| 201 |
protected static function usableEntries($entries): array |
| 202 |
{ |
| 203 |
if (!is_array($entries)) { |
| 204 |
return []; |
| 205 |
} |
| 206 |
|
| 207 |
$usable = []; |
| 208 |
|
| 209 |
foreach ($entries as $entry) { |
| 210 |
if (!is_array($entry)) { |
| 211 |
continue; |
| 212 |
} |
| 213 |
|
| 214 |
$slug = Arr::get($entry, 'slug', ''); |
| 215 |
$raw = (string)Arr::get($entry, 'color', ''); |
| 216 |
$hex = ColorMath::hex($raw); |
| 217 |
|
| 218 |
// A literal colour is preferred because it can also be mixed and |
| 219 |
// measured. A reference cannot be resolved here, but the browser |
| 220 |
// resolves it perfectly well, so it is kept as something we can |
| 221 |
// write — and when it carries a hex fallback, that fallback is the |
| 222 |
// colour it can be measured as too. |
| 223 |
$value = $hex !== '' ? $hex : self::safeReference($raw); |
| 224 |
|
| 225 |
// A bare reference to a property the active vendor itself declares |
| 226 |
// is only unreadable to an outsider: the vendor prints it from its |
| 227 |
// own settings, and those are one function call away. Attaching |
| 228 |
// that value as the fallback turns the reference into the |
| 229 |
// measurable shape — the browser still follows the live property, |
| 230 |
// the fallback is only what it is measured as here. |
| 231 |
if ($hex === '' && $value !== '' && self::measurable($value) === '') { |
| 232 |
$property = substr($value, 4, -1); |
| 233 |
$vendorHex = (string)Arr::get(self::vendorValues(), $property, ''); |
| 234 |
|
| 235 |
if ($vendorHex !== '') { |
| 236 |
$value = 'var(' . $property . ', ' . $vendorHex . ')'; |
| 237 |
} |
| 238 |
} |
| 239 |
|
| 240 |
if (!$slug || $value === '') { |
| 241 |
continue; |
| 242 |
} |
| 243 |
|
| 244 |
$usable[(string)$slug] = [ |
| 245 |
'slug' => (string)$slug, |
| 246 |
'name' => (string)Arr::get($entry, 'name', $slug), |
| 247 |
'color' => self::measurable($value), |
| 248 |
'value' => $value, |
| 249 |
]; |
| 250 |
} |
| 251 |
|
| 252 |
return array_values($usable); |
| 253 |
} |
| 254 |
|
| 255 |
/** |
| 256 |
* The current values of the properties the active vendor declares, keyed |
| 257 |
* by property name. |
| 258 |
* |
| 259 |
* Each mapping reads the same source the vendor prints the property from, |
| 260 |
* so customisations are included — the customiser saves into the very |
| 261 |
* option being read. Astra prints `--ast-global-color-N` from |
| 262 |
* `astra_get_option('global-color-palette')` (its |
| 263 |
* `generate_global_palette_style()`), and Kadence prints |
| 264 |
* `--global-paletteN` from `kadence()->palette_option('paletteN')` (its |
| 265 |
* styles component). Blocksy needs no entry: its palette already ships |
| 266 |
* with hex fallbacks. |
| 267 |
* |
| 268 |
* The values are only ever used as the fallback half of `var(--x, #hex)`, |
| 269 |
* so a wrong or stale answer cannot repaint anything — the browser keeps |
| 270 |
* resolving the live property — it can only mis-measure, which is where |
| 271 |
* resolve() refusing an unmeasurable button still protects the store. |
| 272 |
* |
| 273 |
* @return array Property => hex. |
| 274 |
*/ |
| 275 |
protected static function vendorValues(): array |
| 276 |
{ |
| 277 |
if (self::$cachedVendorValues !== null) { |
| 278 |
return self::$cachedVendorValues; |
| 279 |
} |
| 280 |
|
| 281 |
$values = []; |
| 282 |
|
| 283 |
if (function_exists('astra_get_option')) { |
| 284 |
$palette = astra_get_option('global-color-palette'); |
| 285 |
$colors = is_array($palette) ? Arr::get($palette, 'palette', []) : []; |
| 286 |
|
| 287 |
foreach ((array)$colors as $index => $color) { |
| 288 |
$values['--ast-global-color-' . $index] = (string)$color; |
| 289 |
} |
| 290 |
} |
| 291 |
|
| 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 |
} |
| 301 |
} |
| 302 |
|
| 303 |
/** |
| 304 |
* Filter the vendor-declared custom property values used to measure |
| 305 |
* bare palette references, keyed by property name (`--x` => `#hex`). |
| 306 |
* |
| 307 |
* @param array $values Property => colour. |
| 308 |
*/ |
| 309 |
$values = apply_filters('fluent_cart/theme/vendor_values', $values); |
| 310 |
|
| 311 |
// A filter is just another source of values: only a real property |
| 312 |
// name paired with a real hex survives. Kadence's palette10 can be an |
| 313 |
// oklch() expression, which this drops too — an expression cannot be |
| 314 |
// measured, and it must never ride into a declaration as a fallback. |
| 315 |
$clean = []; |
| 316 |
|
| 317 |
if (is_array($values)) { |
| 318 |
foreach ($values as $property => $color) { |
| 319 |
$colorHex = ColorMath::hex((string)$color); |
| 320 |
|
| 321 |
if ($colorHex !== '' && preg_match('/^--[A-Za-z0-9_-]+$/', (string)$property)) { |
| 322 |
$clean[(string)$property] = $colorHex; |
| 323 |
} |
| 324 |
} |
| 325 |
} |
| 326 |
|
| 327 |
return self::$cachedVendorValues = $clean; |
| 328 |
} |
| 329 |
|
| 330 |
/** |
| 331 |
* Accept a bare custom-property reference, and nothing more. |
| 332 |
* |
| 333 |
* Themes built on the page-builder stack — Astra, Kadence, GeneratePress — |
| 334 |
* publish their palette as `var(--ast-global-color-0)` rather than as a |
| 335 |
* literal, because the real value lives in their own settings and is |
| 336 |
* emitted as a custom property at runtime. Refusing those makes theme |
| 337 |
* inheritance do nothing on a large share of real stores. |
| 338 |
* |
| 339 |
* Only the single-argument form is accepted: no fallback expression, no |
| 340 |
* nesting, no parentheses beyond the one pair. This string is written into |
| 341 |
* a declaration on every storefront page, so the grammar is kept narrow |
| 342 |
* enough that nothing else can ride along inside it. |
| 343 |
* |
| 344 |
* @param string $value |
| 345 |
* @return string The normalised reference, or '' when it is not one. |
| 346 |
*/ |
| 347 |
protected static function safeReference($value): string |
| 348 |
{ |
| 349 |
$value = trim((string)$value); |
| 350 |
|
| 351 |
if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*\)$/', $value, $matches)) { |
| 352 |
return 'var(' . $matches[1] . ')'; |
| 353 |
} |
| 354 |
|
| 355 |
// One fallback shape is allowed, and only one: a hex colour. Customify |
| 356 |
// publishes its whole palette as `var(--customify-primary, #0e7c7b)` — |
| 357 |
// the reference follows the customiser live, and the fallback is the |
| 358 |
// one kind of fallback that is itself checkable. `red`, expressions and |
| 359 |
// nested var() stay refused. |
| 360 |
if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { |
| 361 |
return 'var(' . $matches[1] . ', ' . strtolower($matches[2]) . ')'; |
| 362 |
} |
| 363 |
|
| 364 |
return ''; |
| 365 |
} |
| 366 |
|
| 367 |
/** |
| 368 |
* The colour a value can actually be measured as. |
| 369 |
* |
| 370 |
* A hex is itself. A reference with a hex fallback is measured as the |
| 371 |
* fallback — the theme shipped it as the value to use when the property is |
| 372 |
* missing, which makes it the theme's own best answer for what the colour |
| 373 |
* is. A bare reference measures as nothing. |
| 374 |
* |
| 375 |
* @param string $value |
| 376 |
* @return string Hex, or ''. |
| 377 |
*/ |
| 378 |
public static function measurable($value): string |
| 379 |
{ |
| 380 |
$value = (string)$value; |
| 381 |
$hex = ColorMath::hex($value); |
| 382 |
|
| 383 |
if ($hex !== '') { |
| 384 |
return $hex; |
| 385 |
} |
| 386 |
|
| 387 |
if (preg_match('/^var\(\s*--[A-Za-z0-9_-]+\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { |
| 388 |
return strtolower($matches[1]); |
| 389 |
} |
| 390 |
|
| 391 |
return ''; |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Whether the active theme gave us anything usable. |
| 396 |
* |
| 397 |
* @return bool |
| 398 |
*/ |
| 399 |
public static function available(): bool |
| 400 |
{ |
| 401 |
return count(self::palette()) > 0; |
| 402 |
} |
| 403 |
|
| 404 |
/** |
| 405 |
* Whether the theme told us anything at all to inherit from. |
| 406 |
* |
| 407 |
* A theme can publish a palette we cannot read — several popular ones |
| 408 |
* declare theirs as `var(--theme-colour-0)` references — and it can set a |
| 409 |
* 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. |
| 412 |
* Only when all of them come back empty is there genuinely nothing to |
| 413 |
* inherit, and in that case inheriting must stay out of the way rather |
| 414 |
* than write FluentCart's own colours back to the page and call them the |
| 415 |
* theme's. |
| 416 |
* |
| 417 |
* @return bool |
| 418 |
*/ |
| 419 |
public static function hasUsableSource(): bool |
| 420 |
{ |
| 421 |
if (self::available()) { |
| 422 |
return true; |
| 423 |
} |
| 424 |
|
| 425 |
$globals = self::globalColors(); |
| 426 |
|
| 427 |
if (Arr::get($globals, 'background', '') !== '' || Arr::get($globals, 'text', '') !== '') { |
| 428 |
return true; |
| 429 |
} |
| 430 |
|
| 431 |
return Arr::get(self::buttonGlobals(), 'background', '') !== ''; |
| 432 |
} |
| 433 |
|
| 434 |
/** |
| 435 |
* Look up one palette colour by slug. |
| 436 |
* |
| 437 |
* @param string $slug |
| 438 |
* @return string Hex, or '' when the slug is unknown. |
| 439 |
*/ |
| 440 |
public static function color(string $slug): string |
| 441 |
{ |
| 442 |
return self::lookup($slug, 'color'); |
| 443 |
} |
| 444 |
|
| 445 |
/** |
| 446 |
* What one palette slug resolves to for writing into CSS. |
| 447 |
* |
| 448 |
* Unlike color(), this can be a custom-property reference — use it when |
| 449 |
* emitting a declaration, and color() when the value has to be reasoned |
| 450 |
* about. |
| 451 |
* |
| 452 |
* @param string $slug |
| 453 |
* @return string Hex, a var() reference, or '' when the slug is unknown. |
| 454 |
*/ |
| 455 |
public static function value(string $slug): string |
| 456 |
{ |
| 457 |
return self::lookup($slug, 'value'); |
| 458 |
} |
| 459 |
|
| 460 |
/** |
| 461 |
* Read one field off a palette entry. |
| 462 |
* |
| 463 |
* @param string $slug |
| 464 |
* @param string $field |
| 465 |
* @return string |
| 466 |
*/ |
| 467 |
protected static function lookup(string $slug, string $field): string |
| 468 |
{ |
| 469 |
if ($slug === '') { |
| 470 |
return ''; |
| 471 |
} |
| 472 |
|
| 473 |
foreach (self::palette() as $entry) { |
| 474 |
if (Arr::get($entry, 'slug') === $slug) { |
| 475 |
return (string)Arr::get($entry, $field, ''); |
| 476 |
} |
| 477 |
} |
| 478 |
|
| 479 |
return ''; |
| 480 |
} |
| 481 |
|
| 482 |
/** |
| 483 |
* The theme's global background and text colours, when it sets them. |
| 484 |
* |
| 485 |
* @return array ['background' => hex, 'text' => hex]; either may be ''. |
| 486 |
*/ |
| 487 |
public static function globalColors(): array |
| 488 |
{ |
| 489 |
$colors = ['background' => '', 'text' => '']; |
| 490 |
|
| 491 |
if (!function_exists('wp_get_global_styles')) { |
| 492 |
return $colors; |
| 493 |
} |
| 494 |
|
| 495 |
$styles = wp_get_global_styles(['color']); |
| 496 |
|
| 497 |
if (!is_array($styles)) { |
| 498 |
return $colors; |
| 499 |
} |
| 500 |
|
| 501 |
$colors['background'] = self::resolveReference(Arr::get($styles, 'background', '')); |
| 502 |
$colors['text'] = self::resolveReference(Arr::get($styles, 'text', '')); |
| 503 |
|
| 504 |
return $colors; |
| 505 |
} |
| 506 |
|
| 507 |
/** |
| 508 |
* The button pair the theme declares and the site editor edits. |
| 509 |
* |
| 510 |
* Styles → Buttons in the site editor saves to |
| 511 |
* `styles.elements.button.color`, and block themes ship their own pair |
| 512 |
* there in theme.json — the most explicit statement either can make |
| 513 |
* about the buttons. The two origins merge per property, the owner's |
| 514 |
* edits over the theme's, which is exactly how WordPress itself paints |
| 515 |
* the button. Core's origin is left out on purpose: WordPress ships a |
| 516 |
* default button colour (#32373c) for every site, and counting that as |
| 517 |
* "the theme said something" would mean no theme could ever stand aside. |
| 518 |
* Backgrounds usually arrive as a preset reference |
| 519 |
* (`var:preset|color|contrast`), which resolves through the palette the |
| 520 |
* same way the page pair's do. |
| 521 |
* |
| 522 |
* @return array ['background' => hex, 'text' => hex]; either may be ''. |
| 523 |
*/ |
| 524 |
public static function buttonGlobals(): array |
| 525 |
{ |
| 526 |
$colors = ['background' => '', 'text' => '']; |
| 527 |
|
| 528 |
if (!class_exists('WP_Theme_JSON_Resolver')) { |
| 529 |
return $colors; |
| 530 |
} |
| 531 |
|
| 532 |
foreach (['get_theme_data', 'get_user_data'] as $origin) { |
| 533 |
if (!method_exists('WP_Theme_JSON_Resolver', $origin)) { |
| 534 |
continue; |
| 535 |
} |
| 536 |
|
| 537 |
$data = \WP_Theme_JSON_Resolver::$origin(); |
| 538 |
|
| 539 |
if (!is_object($data) || !method_exists($data, 'get_raw_data')) { |
| 540 |
continue; |
| 541 |
} |
| 542 |
|
| 543 |
$pair = Arr::get((array)$data->get_raw_data(), 'styles.elements.button.color', []); |
| 544 |
|
| 545 |
foreach (['background', 'text'] as $half) { |
| 546 |
$value = self::resolveReference((string)Arr::get((array)$pair, $half, '')); |
| 547 |
|
| 548 |
if ($value !== '') { |
| 549 |
$colors[$half] = $value; |
| 550 |
} |
| 551 |
} |
| 552 |
} |
| 553 |
|
| 554 |
return $colors; |
| 555 |
} |
| 556 |
|
| 557 |
/** |
| 558 |
* Resolve a theme.json colour, which may be a preset reference rather |
| 559 |
* than a literal colour. |
| 560 |
* |
| 561 |
* @param string $value |
| 562 |
* @return string Hex, or ''. |
| 563 |
*/ |
| 564 |
protected static function resolveReference($value): string |
| 565 |
{ |
| 566 |
$value = trim((string)$value); |
| 567 |
|
| 568 |
if ($value === '') { |
| 569 |
return ''; |
| 570 |
} |
| 571 |
|
| 572 |
if (preg_match('/var:preset\|color\|([\w-]+)/', $value, $matches)) { |
| 573 |
return self::color($matches[1]); |
| 574 |
} |
| 575 |
|
| 576 |
if (preg_match('/var\(\s*--wp--preset--color--([\w-]+)/', $value, $matches)) { |
| 577 |
return self::color($matches[1]); |
| 578 |
} |
| 579 |
|
| 580 |
return ColorMath::hex($value); |
| 581 |
} |
| 582 |
|
| 583 |
/** |
| 584 |
* Which palette slug each anchor role resolves to. |
| 585 |
* |
| 586 |
* @return array Role => palette slug, '' when nothing matched. |
| 587 |
*/ |
| 588 |
public static function anchorMap(): array |
| 589 |
{ |
| 590 |
$map = []; |
| 591 |
|
| 592 |
foreach (self::$anchorCandidates as $role => $slugs) { |
| 593 |
$map[$role] = ''; |
| 594 |
|
| 595 |
foreach ($slugs as $slug) { |
| 596 |
// value(), not color(): a slug the theme publishes only as a |
| 597 |
// reference still counts as a colour the theme offers. |
| 598 |
if (self::value($slug) !== '') { |
| 599 |
$map[$role] = $slug; |
| 600 |
break; |
| 601 |
} |
| 602 |
} |
| 603 |
} |
| 604 |
|
| 605 |
/** |
| 606 |
* Filter which theme palette slug drives each anchor role. |
| 607 |
* |
| 608 |
* @param array $map Role => palette slug. |
| 609 |
*/ |
| 610 |
return apply_filters('fluent_cart/theme/anchor_map', $map); |
| 611 |
} |
| 612 |
|
| 613 |
/** |
| 614 |
* The four anchors, exactly as far as the theme could be read. |
| 615 |
* |
| 616 |
* An anchor the theme did not supply comes back empty rather than standing |
| 617 |
* in FluentCart's own colour for it. The stylesheet that uses the property |
| 618 |
* already carries that colour as its `var(--fct-x, <fallback>)` fallback, |
| 619 |
* so substituting it here would only mean writing the same value twice — |
| 620 |
* and writing it under the active theme's name, which is how a store on a |
| 621 |
* theme nothing could be read from came to report itself as inheriting |
| 622 |
* while wearing FluentCart's palette. |
| 623 |
* |
| 624 |
* @return array Role => hex, a custom-property reference, or '' when the |
| 625 |
* theme said nothing about it. |
| 626 |
*/ |
| 627 |
public static function anchors(): array |
| 628 |
{ |
| 629 |
$map = self::anchorMap(); |
| 630 |
$globals = self::globalColors(); |
| 631 |
|
| 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. |
| 641 |
$gsSurface = (string)Arr::get($globals, 'background', ''); |
| 642 |
$gsText = (string)Arr::get($globals, 'text', ''); |
| 643 |
|
| 644 |
if ($gsSurface !== '' && $gsText !== '') { |
| 645 |
// The rendered pair. |
| 646 |
$surface = $gsSurface; |
| 647 |
$text = $gsText; |
| 648 |
} else { |
| 649 |
// The published pair. A lone global-styles fragment is dropped |
| 650 |
// rather than paired with a guess. |
| 651 |
$surface = self::value(Arr::get($map, 'surface', '')); |
| 652 |
$text = self::value(Arr::get($map, 'text', '')); |
| 653 |
|
| 654 |
if ($text === '' && ColorMath::hex($surface) !== '') { |
| 655 |
// A lone measurable surface derives its partner by contrast, |
| 656 |
// the same way button text does — leaving it unwritten would |
| 657 |
// drop the stylesheets' dark fallback text onto a surface |
| 658 |
// that may itself be dark. |
| 659 |
$text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448'); |
| 660 |
} |
| 661 |
} |
| 662 |
|
| 663 |
$accent = self::value(Arr::get($map, 'accent', '')); |
| 664 |
|
| 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. |
| 671 |
$button = self::buttonGlobals(); |
| 672 |
$buttonText = ''; |
| 673 |
|
| 674 |
if ($button['background'] !== '') { |
| 675 |
$buttonBg = $button['background']; |
| 676 |
$buttonText = $button['text']; |
| 677 |
} else { |
| 678 |
$buttonBg = self::value(Arr::get($map, 'button_bg', '')); |
| 679 |
|
| 680 |
if ($buttonBg === '') { |
| 681 |
// Not a default — the theme's own accent, which is what a |
| 682 |
// theme that names one button colour almost always means by it. |
| 683 |
$buttonBg = $accent; |
| 684 |
} |
| 685 |
} |
| 686 |
|
| 687 |
return [ |
| 688 |
'surface' => $surface, |
| 689 |
'text' => $text, |
| 690 |
'accent' => $accent, |
| 691 |
'button_bg' => $buttonBg, |
| 692 |
'button_text' => $buttonText, |
| 693 |
]; |
| 694 |
} |
| 695 |
|
| 696 |
/** |
| 697 |
* Resolve every semantic role the theme can actually supply. |
| 698 |
* |
| 699 |
* The first block is what the theme said. The second is derived from it — |
| 700 |
* no theme publishes a hairline border or a placeholder tone, so those are |
| 701 |
* mixed out of the body text and the surface. |
| 702 |
* |
| 703 |
* Deriving needs two real colours to mix between. A reference cannot be |
| 704 |
* read here (the browser resolves it on the page, where we are not), and an |
| 705 |
* anchor the theme never supplied is not a colour at all, so in both cases |
| 706 |
* the derived roles come back empty and nothing is written for them. The |
| 707 |
* stylesheet's own fallback is then what applies, which is the same colour |
| 708 |
* it would have been given — arrived at once instead of twice. |
| 709 |
* |
| 710 |
* @return array Role => hex, a reference, or '' when it could not be |
| 711 |
* resolved. |
| 712 |
*/ |
| 713 |
public static function resolve(): array |
| 714 |
{ |
| 715 |
if (self::$cachedRoles !== null) { |
| 716 |
return self::$cachedRoles; |
| 717 |
} |
| 718 |
|
| 719 |
$anchors = self::anchors(); |
| 720 |
$surface = (string)Arr::get($anchors, 'surface', ''); |
| 721 |
$text = (string)Arr::get($anchors, 'text', ''); |
| 722 |
$buttonBg = (string)Arr::get($anchors, 'button_bg', ''); |
| 723 |
|
| 724 |
// Measured, not taken literally: a reference with a hex fallback mixes |
| 725 |
// as the fallback the theme shipped, so a Customify-style palette |
| 726 |
// derives instead of standing down. |
| 727 |
$textHex = self::measurable($text); |
| 728 |
$surfaceHex = self::measurable($surface); |
| 729 |
$mixable = $textHex !== '' && $surfaceHex !== ''; |
| 730 |
|
| 731 |
$derived = $mixable |
| 732 |
? [ |
| 733 |
'surface_alt' => ColorMath::mix($textHex, $surfaceHex, 4), |
| 734 |
'surface_mute' => ColorMath::mix($textHex, $surfaceHex, 8), |
| 735 |
'divider' => ColorMath::mix($textHex, $surfaceHex, 10), |
| 736 |
'border' => ColorMath::mix($textHex, $surfaceHex, 18), |
| 737 |
'text_placeholder' => ColorMath::mix($textHex, $surfaceHex, 45), |
| 738 |
'text_muted' => ColorMath::mix($textHex, $surfaceHex, 68), |
| 739 |
] |
| 740 |
: [ |
| 741 |
'surface_alt' => '', |
| 742 |
'surface_mute' => '', |
| 743 |
'divider' => '', |
| 744 |
'border' => '', |
| 745 |
'text_placeholder' => '', |
| 746 |
'text_muted' => '', |
| 747 |
]; |
| 748 |
|
| 749 |
// 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. |
| 757 |
$buttonBgHex = self::measurable($buttonBg); |
| 758 |
$ownText = (string)Arr::get($anchors, 'button_text', ''); |
| 759 |
|
| 760 |
if ($buttonBgHex !== '') { |
| 761 |
$derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableOn($buttonBgHex); |
| 762 |
} else { |
| 763 |
$anchors['button_bg'] = ''; |
| 764 |
$derived['button_text'] = ''; |
| 765 |
} |
| 766 |
|
| 767 |
// The outline secondary button is FluentCart's own invention, and its |
| 768 |
// outline is the hierarchy: it always keeps the page surface as its |
| 769 |
// background. Wearing the theme's stated pair outright painted BOTH |
| 770 |
// CTAs identically (Twenty Twenty-Five states black-on-white, so Add |
| 771 |
// to Cart went black beside a black Buy Now). Instead the label |
| 772 |
// borrows a half of the pair AS WORN by the primary button (stated |
| 773 |
// text, or the measured partner of a lone stated background) — |
| 774 |
// whichever half reads better on the page surface, and only when |
| 775 |
// that half actually reads (WCAG 4.5:1); otherwise the page's own |
| 776 |
// text stays, since a borrowed label that cannot be read matches |
| 777 |
// nothing worth matching. Measurement uses the surface's hex, which |
| 778 |
// for a reference is its fallback — the theme's own best answer, the |
| 779 |
// same trust every derived tone already extends (spec 59). |
| 780 |
$derived['secondary_button_bg'] = $surface; |
| 781 |
$derived['secondary_button_text'] = $text; |
| 782 |
|
| 783 |
if (self::buttonGlobals()['background'] !== '' && $buttonBgHex !== '' && $surfaceHex !== '') { |
| 784 |
$pairText = (string)$derived['button_text']; |
| 785 |
|
| 786 |
$label = ColorMath::contrast($surfaceHex, $pairText) >= ColorMath::contrast($surfaceHex, $buttonBgHex) |
| 787 |
? $pairText |
| 788 |
: $buttonBgHex; |
| 789 |
|
| 790 |
if (ColorMath::contrast($surfaceHex, $label) >= 4.5) { |
| 791 |
$derived['secondary_button_text'] = $label; |
| 792 |
} |
| 793 |
} |
| 794 |
|
| 795 |
/** |
| 796 |
* Filter the resolved semantic role colours. |
| 797 |
* |
| 798 |
* @param array $roles Role => hex. |
| 799 |
*/ |
| 800 |
self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived)); |
| 801 |
|
| 802 |
return self::$cachedRoles; |
| 803 |
} |
| 804 |
|
| 805 |
/** |
| 806 |
* A one-line description of what the active theme offers, shown under the |
| 807 |
* inherit option so the store owner knows whether it is worth picking. |
| 808 |
* |
| 809 |
* @return string |
| 810 |
*/ |
| 811 |
public static function sourceLabel(): string |
| 812 |
{ |
| 813 |
$count = count(self::palette()); |
| 814 |
|
| 815 |
if (!$count) { |
| 816 |
return __('The active theme does not publish a colour palette, so inheriting would fall back to FluentCart\'s own colours.', 'fluent-cart'); |
| 817 |
} |
| 818 |
|
| 819 |
$theme = wp_get_theme(); |
| 820 |
|
| 821 |
return sprintf( |
| 822 |
/* translators: 1: active theme name, 2: number of palette colours the theme publishes */ |
| 823 |
_n('%1$s provides %2$d palette colour.', '%1$s provides %2$d palette colours.', $count, 'fluent-cart'), |
| 824 |
$theme->get('Name'), |
| 825 |
$count |
| 826 |
); |
| 827 |
} |
| 828 |
} |
| 829 |
|