PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / trunk
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler vtrunk
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 1.3.19 All 49 releases
fluent-cart / app / Services / Theme / ThemePalette.php

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

1,143 lines 44.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 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