PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
fluent-cart / app / Services / Theme / ThemePalette.php

ThemePalette.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.0, at app/Services/Theme/ThemePalette.php

1,124 lines 43.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 colours (Styles → Buttons in the
582 * site editor) — a background, a text, or both — and that is still an
583 * explicit configuration to wear.
584 * Only when all of them come back empty is there genuinely nothing to
585 * inherit, and in that case inheriting must stay out of the way rather
586 * than write FluentCart's own colours back to the page and call them the
587 * theme's.
588 *
589 * @return bool
590 */
591 public static function hasUsableSource(): bool
592 {
593 if (self::available()) {
594 return true;
595 }
596
597 $globals = self::globalColors();
598
599 if (Arr::get($globals, 'background', '') !== '' || Arr::get($globals, 'text', '') !== '') {
600 return true;
601 }
602
603 if (self::settingsRoles()) {
604 return true;
605 }
606
607 $button = self::buttonGlobals();
608
609 return Arr::get($button, 'background', '') !== '' || Arr::get($button, 'text', '') !== '';
610 }
611
612 /**
613 * Look up one palette colour by slug.
614 *
615 * @param string $slug
616 * @return string Hex, or '' when the slug is unknown.
617 */
618 public static function color(string $slug): string
619 {
620 return self::lookup($slug, 'color');
621 }
622
623 /**
624 * What one palette slug resolves to for writing into CSS.
625 *
626 * Unlike color(), this can be a custom-property reference — use it when
627 * emitting a declaration, and color() when the value has to be reasoned
628 * about.
629 *
630 * @param string $slug
631 * @return string Hex, a var() reference, or '' when the slug is unknown.
632 */
633 public static function value(string $slug): string
634 {
635 return self::lookup($slug, 'value');
636 }
637
638 /**
639 * Read one field off a palette entry.
640 *
641 * @param string $slug
642 * @param string $field
643 * @return string
644 */
645 protected static function lookup(string $slug, string $field): string
646 {
647 if ($slug === '') {
648 return '';
649 }
650
651 foreach (self::palette() as $entry) {
652 if (Arr::get($entry, 'slug') === $slug) {
653 return (string)Arr::get($entry, $field, '');
654 }
655 }
656
657 return '';
658 }
659
660 /**
661 * The theme's global background and text colours, when it sets them.
662 *
663 * @return array ['background' => hex, 'text' => hex]; either may be ''.
664 */
665 public static function globalColors(): array
666 {
667 $colors = ['background' => '', 'text' => ''];
668
669 if (!function_exists('wp_get_global_styles')) {
670 return $colors;
671 }
672
673 $styles = wp_get_global_styles(['color']);
674
675 if (!is_array($styles)) {
676 return $colors;
677 }
678
679 $colors['background'] = self::resolveReference(Arr::get($styles, 'background', ''));
680 $colors['text'] = self::resolveReference(Arr::get($styles, 'text', ''));
681
682 return $colors;
683 }
684
685 /**
686 * The button pair the theme declares and the site editor edits.
687 *
688 * Styles → Buttons in the site editor saves to
689 * `styles.elements.button.color`, and block themes ship their own pair
690 * there in theme.json — the most explicit statement either can make
691 * about the buttons. The two origins merge per property, the owner's
692 * edits over the theme's, which is exactly how WordPress itself paints
693 * the button. Core's origin is left out on purpose: WordPress ships a
694 * default button colour (#32373c) for every site, and counting that as
695 * "the theme said something" would mean no theme could ever stand aside.
696 * Backgrounds usually arrive as a preset reference
697 * (`var:preset|color|contrast`), which resolves through the palette the
698 * same way the page pair's do.
699 *
700 * The `:hover` pair (`styles.elements.button.:hover.color`) is read the
701 * same way, into `hover_background` / `hover_text`.
702 *
703 * @return array ['background' => hex, 'text' => hex, 'hover_background' => hex,
704 * 'hover_text' => hex]; any may be ''.
705 */
706 public static function buttonGlobals(): array
707 {
708 $colors = ['background' => '', 'text' => '', 'hover_background' => '', 'hover_text' => ''];
709
710 if (!class_exists('WP_Theme_JSON_Resolver')) {
711 return $colors;
712 }
713
714 foreach (['get_theme_data', 'get_user_data'] as $origin) {
715 if (!method_exists('WP_Theme_JSON_Resolver', $origin)) {
716 continue;
717 }
718
719 $data = \WP_Theme_JSON_Resolver::$origin();
720
721 if (!is_object($data) || !method_exists($data, 'get_raw_data')) {
722 continue;
723 }
724
725 $raw = (array)$data->get_raw_data();
726 $pairs = [
727 '' => Arr::get($raw, 'styles.elements.button.color', []),
728 'hover_' => Arr::get($raw, 'styles.elements.button.:hover.color', []),
729 ];
730
731 foreach ($pairs as $prefix => $pair) {
732 foreach (['background', 'text'] as $half) {
733 $value = self::resolveReference((string)Arr::get((array)$pair, $half, ''));
734
735 if ($value !== '') {
736 $colors[$prefix . $half] = $value;
737 }
738 }
739 }
740 }
741
742 return $colors;
743 }
744
745 /**
746 * Resolve a theme.json colour, which may be a preset reference rather
747 * than a literal colour.
748 *
749 * @param string $value
750 * @return string Hex, or ''.
751 */
752 protected static function resolveReference($value): string
753 {
754 $value = trim((string)$value);
755
756 if ($value === '') {
757 return '';
758 }
759
760 if (preg_match('/var:preset\|color\|([\w-]+)/', $value, $matches)) {
761 return self::color($matches[1]);
762 }
763
764 if (preg_match('/var\(\s*--wp--preset--color--([\w-]+)/', $value, $matches)) {
765 return self::color($matches[1]);
766 }
767
768 return ColorMath::hex($value);
769 }
770
771 /**
772 * Which palette slug each anchor role resolves to.
773 *
774 * @return array Role => palette slug, '' when nothing matched.
775 */
776 public static function anchorMap(): array
777 {
778 $map = [];
779
780 foreach (self::$anchorCandidates as $role => $slugs) {
781 $map[$role] = '';
782
783 foreach ($slugs as $slug) {
784 // value(), not color(): a slug the theme publishes only as a
785 // reference still counts as a colour the theme offers.
786 if (self::value($slug) !== '') {
787 $map[$role] = $slug;
788 break;
789 }
790 }
791 }
792
793 /**
794 * Filter which theme palette slug drives each anchor role.
795 *
796 * @param array $map Role => palette slug.
797 */
798 return apply_filters('fluent_cart/theme/anchor_map', $map);
799 }
800
801 /**
802 * The four anchors, exactly as far as the theme could be read.
803 *
804 * An anchor the theme did not supply comes back empty rather than standing
805 * in FluentCart's own colour for it. The stylesheet that uses the property
806 * already carries that colour as its `var(--fct-x, <fallback>)` fallback,
807 * so substituting it here would only mean writing the same value twice —
808 * and writing it under the active theme's name, which is how a store on a
809 * theme nothing could be read from came to report itself as inheriting
810 * while wearing FluentCart's palette.
811 *
812 * @return array Role => hex, a custom-property reference, or '' when the
813 * theme said nothing about it.
814 */
815 public static function anchors(): array
816 {
817 $map = self::anchorMap();
818 $globals = self::globalColors();
819 $settings = self::settingsRoles();
820
821 // Global styles outrank the palette — the palette lists the AVAILABLE
822 // colours, global styles say what the body actually RENDERS, and on
823 // Twenty Twenty-Five's dark style variation those disagree. The
824 // surface comes from global styles, then the theme's settings, then
825 // the palette. A body text the owner stated is worn as given below,
826 // whichever source the surface came from.
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). A surface stated without a text takes the
839 // palette's own text when it reads there (4.5:1) — Astra saves
840 // Body Text empty and still means its text swatch — and only an
841 // unreadable one is replaced by a measured partner.
842 $surface = $setSurface;
843 $text = (string)Arr::get($settings, 'text', '');
844 $surfaceHex = self::measurable($surface);
845
846 if ($text === '' && $surfaceHex !== '') {
847 $paletteText = self::value(Arr::get($map, 'text', ''));
848 $paletteTextHex = self::measurable($paletteText);
849
850 $text = $paletteTextHex !== '' && ColorMath::contrast($surfaceHex, $paletteTextHex) >= 4.5
851 ? $paletteText
852 : ColorMath::readableOn($surfaceHex, '#F3F4F6', '#2F3448');
853 }
854 } else {
855 // The published pair.
856 $surface = self::value(Arr::get($map, 'surface', ''));
857 $text = self::value(Arr::get($map, 'text', ''));
858
859 if ($text === '' && ColorMath::hex($surface) !== '') {
860 // A lone measurable surface derives its partner by contrast,
861 // the same way button text does — leaving it unwritten would
862 // drop the stylesheets' dark fallback text onto a surface
863 // that may itself be dark.
864 $text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448');
865 }
866 }
867
868 // A body text the owner chose is worn as given, even without a stated
869 // background — global styles first, then the theme's settings.
870 $statedText = $gsText !== '' ? $gsText : (string)Arr::get($settings, 'text', '');
871
872 if ($statedText !== '') {
873 $text = $statedText;
874 }
875
876 $accent = (string)Arr::get($settings, 'accent', '');
877
878 if ($accent === '') {
879 $accent = self::value(Arr::get($map, 'accent', ''));
880 }
881
882 // What the owner set in the site editor (Styles → Buttons) outranks
883 // every guess the palette could offer, then the theme's settings. A
884 // background brings its own text, or gets one measured against it in
885 // resolve(); a text the owner set alone is worn as given on whatever
886 // background the button otherwise gets.
887 $button = self::buttonGlobals();
888 $buttonText = '';
889
890 if ($button['background'] !== '') {
891 $buttonBg = $button['background'];
892 $buttonText = $button['text'];
893 } elseif (Arr::get($settings, 'button_bg', '') !== '') {
894 // Next, the button the theme's settings state, with its partner.
895 $buttonBg = (string)$settings['button_bg'];
896 $buttonText = (string)Arr::get($settings, 'button_text', '');
897 } else {
898 $buttonBg = self::value(Arr::get($map, 'button_bg', ''));
899
900 if ($buttonBg === '') {
901 // Not a default — the theme's own accent, which is what a
902 // theme that names one button colour almost always means by it.
903 $buttonBg = $accent;
904 }
905
906 $buttonText = $button['text'] !== '' ? $button['text'] : (string)Arr::get($settings, 'button_text', '');
907 }
908
909 return [
910 'surface' => $surface,
911 'text' => $text,
912 'accent' => $accent,
913 'button_bg' => $buttonBg,
914 'button_text' => $buttonText,
915 ];
916 }
917
918 /**
919 * Resolve every semantic role the theme can actually supply.
920 *
921 * The first block is what the theme said. The second is derived from it —
922 * no theme publishes a hairline border or a placeholder tone, so those are
923 * mixed out of the body text and the surface.
924 *
925 * Deriving needs two real colours to mix between. A reference cannot be
926 * read here (the browser resolves it on the page, where we are not), and an
927 * anchor the theme never supplied is not a colour at all, so in both cases
928 * the derived roles come back empty and nothing is written for them. The
929 * stylesheet's own fallback is then what applies, which is the same colour
930 * it would have been given — arrived at once instead of twice.
931 *
932 * @return array Role => hex, a reference, or '' when it could not be
933 * resolved.
934 */
935 public static function resolve(): array
936 {
937 if (self::$cachedRoles !== null) {
938 return self::$cachedRoles;
939 }
940
941 $anchors = self::anchors();
942 $surface = (string)Arr::get($anchors, 'surface', '');
943 $text = (string)Arr::get($anchors, 'text', '');
944 $buttonBg = (string)Arr::get($anchors, 'button_bg', '');
945
946 // Measured, not taken literally: a reference with a hex fallback mixes
947 // as the fallback the theme shipped, so a Customify-style palette
948 // derives instead of standing down.
949 $textHex = self::measurable($text);
950 $surfaceHex = self::measurable($surface);
951 $mixable = $textHex !== '' && $surfaceHex !== '';
952
953 $derived = $mixable
954 ? [
955 'surface_alt' => ColorMath::mix($textHex, $surfaceHex, 4),
956 'surface_mute' => ColorMath::mix($textHex, $surfaceHex, 8),
957 'divider' => ColorMath::mix($textHex, $surfaceHex, 10),
958 'border' => ColorMath::mix($textHex, $surfaceHex, 18),
959 'text_placeholder' => ColorMath::mix($textHex, $surfaceHex, 45),
960 // Muted text is still text: it moves toward the body text only
961 // as far as it must to read (4.5:1). A mid-grey body text
962 // (Divi's #666666) otherwise mixed down to 2.92:1.
963 'text_muted' => ColorMath::readableMix($textHex, $surfaceHex, 68),
964 ]
965 : [
966 'surface_alt' => '',
967 'surface_mute' => '',
968 'divider' => '',
969 'border' => '',
970 'text_placeholder' => '',
971 'text_muted' => '',
972 ];
973
974 // A border the theme's settings state is the border (Astra's Borders);
975 // only an unstated one is mixed.
976 $settings = self::settingsRoles();
977
978 if (Arr::get($settings, 'border', '') !== '') {
979 $derived['border'] = (string)$settings['border'];
980 }
981
982 // Contrast needs a real colour to measure against for the same reason.
983 // Colours the owner chose (the site editor's, or a theme settings
984 // reader's) are worn exactly as given, at any contrast; only a missing
985 // one is measured. A background nobody chose whose value cannot be
986 // measured (a bare palette reference) is not worn at all: no readable
987 // text can be paired with it here, so the stylesheets' own fallback
988 // styles the button instead. One the owner chose is still worn, with
989 // nothing invented beside it.
990 $buttonBgHex = self::measurable($buttonBg);
991 $ownText = (string)Arr::get($anchors, 'button_text', '');
992 $stated = self::buttonGlobals();
993 $buttonStated = $stated['background'] !== '' || Arr::get($settings, 'button_bg', '') !== '';
994
995 if ($buttonBgHex !== '') {
996 $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableText($buttonBgHex);
997 } else {
998 if (!$buttonStated) {
999 $anchors['button_bg'] = '';
1000 }
1001
1002 $derived['button_text'] = $ownText;
1003 }
1004
1005 // The hover follows the button it belongs to: unwritten when the button
1006 // is. The site editor's `:hover` pair outranks any guess; otherwise the
1007 // button colour moves away from itself — darker for a light button,
1008 // lighter for a dark one, and never for a button that cannot be
1009 // measured. Hover text given with it is worn as given; otherwise the
1010 // resting text carries over while it still reads (WCAG 4.5:1), and a
1011 // partner is measured when it does not.
1012 $derived['button_hover_bg'] = '';
1013 $derived['button_hover_text'] = '';
1014
1015 if ($buttonBgHex !== '' || $buttonStated) {
1016 $hoverBg = $stated['hover_background'];
1017 $hoverText = $stated['hover_text'];
1018
1019 // The theme settings' hover belongs to the theme settings' button:
1020 // it is only used when that button is the one being worn, never
1021 // paired onto a button the site editor states.
1022 $settingsButton = $stated['background'] === '' && Arr::get($settings, 'button_bg', '') !== '';
1023
1024 if ($hoverBg === '' && $settingsButton && Arr::get($settings, 'button_hover_bg', '') !== '') {
1025 $hoverBg = (string)$settings['button_hover_bg'];
1026
1027 if ($hoverText === '') {
1028 $hoverText = (string)Arr::get($settings, 'button_hover_text', '');
1029 }
1030 }
1031
1032 if ($hoverBg === '' && $buttonBgHex !== '') {
1033 $hoverBg = ColorMath::shiftFromItself($buttonBgHex, 12);
1034 }
1035
1036 $hoverBgHex = self::measurable($hoverBg);
1037
1038 if ($hoverText === '' && $hoverBgHex !== '') {
1039 $hoverText = self::hoverTextFor($hoverBgHex, (string)$derived['button_text']);
1040 }
1041
1042 $derived['button_hover_bg'] = $hoverBg;
1043 $derived['button_hover_text'] = $hoverText;
1044 }
1045
1046 // The outline secondary button is FluentCart's own invention, and its
1047 // outline is the hierarchy: it always keeps the page surface as its
1048 // background. Wearing the theme's stated pair outright painted BOTH
1049 // CTAs identically (Twenty Twenty-Five states black-on-white, so Add
1050 // to Cart went black beside a black Buy Now). Instead the label
1051 // borrows a half of the pair AS WORN by the primary button (stated
1052 // text, or the measured partner of a lone stated background) —
1053 // whichever half reads better on the page surface, and only when
1054 // that half actually reads (WCAG 4.5:1); otherwise the page's own
1055 // text stays, since a borrowed label that cannot be read matches
1056 // nothing worth matching. Measurement uses the surface's hex, which
1057 // for a reference is its fallback — the theme's own best answer, the
1058 // same trust every derived tone already extends (spec 59).
1059 $derived['secondary_button_bg'] = $surface;
1060 $derived['secondary_button_text'] = $text;
1061
1062 if (self::buttonGlobals()['background'] !== '' && $buttonBgHex !== '' && $surfaceHex !== '') {
1063 $pairText = (string)$derived['button_text'];
1064
1065 $label = ColorMath::contrast($surfaceHex, $pairText) >= ColorMath::contrast($surfaceHex, $buttonBgHex)
1066 ? $pairText
1067 : $buttonBgHex;
1068
1069 if (ColorMath::contrast($surfaceHex, $label) >= 4.5) {
1070 $derived['secondary_button_text'] = $label;
1071 }
1072 }
1073
1074 /**
1075 * Filter the resolved semantic role colours.
1076 *
1077 * @param array $roles Role => hex.
1078 */
1079 self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived));
1080
1081 return self::$cachedRoles;
1082 }
1083
1084 /**
1085 * The text for a hover background nobody gave a text: the button's
1086 * resting text carries over while it still reads (WCAG 4.5:1), and a
1087 * partner is measured when it does not.
1088 *
1089 * @param string $hoverBgHex
1090 * @param string $restingText
1091 * @return string
1092 */
1093 public static function hoverTextFor(string $hoverBgHex, string $restingText): string
1094 {
1095 return ColorMath::contrast($hoverBgHex, $restingText) >= 4.5
1096 ? $restingText
1097 : ColorMath::readableText($hoverBgHex);
1098 }
1099
1100 /**
1101 * A one-line description of what the active theme offers, shown under the
1102 * inherit option so the store owner knows whether it is worth picking.
1103 *
1104 * @return string
1105 */
1106 public static function sourceLabel(): string
1107 {
1108 $count = count(self::palette());
1109
1110 if (!$count) {
1111 return __('The active theme does not publish a colour palette, so inheriting would fall back to FluentCart\'s own colours.', 'fluent-cart');
1112 }
1113
1114 $theme = wp_get_theme();
1115
1116 return sprintf(
1117 /* translators: 1: active theme name, 2: number of palette colours the theme publishes */
1118 _n('%1$s provides %2$d palette colour.', '%1$s provides %2$d palette colours.', $count, 'fluent-cart'),
1119 $theme->get('Name'),
1120 $count
1121 );
1122 }
1123 }
1124