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

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

829 lines 31.7 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\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