['base', 'background', 'white', 'base-2', 'light', 'ast-global-color-4', 'theme-palette9', 'palette-color-8'], 'text' => ['contrast', 'text', 'foreground', 'black', 'dark', 'contrast-2', 'ast-global-color-3', 'theme-palette4', 'palette-color-3'], 'accent' => ['primary', 'accent', 'brand', 'accent-1', 'theme-1', 'link', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], 'button_bg' => ['primary', 'accent', 'brand', 'contrast', 'accent-1', 'theme-1', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], ]; /** * Request-level cache for the normalised palette. * * @var array|null */ protected static $cachedPalette = null; /** * Request-level cache for the resolved roles. * * @var array|null */ protected static $cachedRoles = null; /** * Request-level cache for the vendor-declared property values. * * @var array|null */ protected static $cachedVendorValues = null; /** * Request-level cache for the roles the theme's own settings state. * * @var array|null */ protected static $cachedSettingsRoles = null; /** * Theme settings readers, keyed by the theme name passed to the * `fluent_cart/theme/settings_roles` filter. The first that applies wins. * * Only one theme runs on a site, so the order only matters when several * vendors' functions are loaded at once (the test stubs). The stricter * checks go first: Blocksy's and Kadence's each need a live instance from * the vendor's API, Divi's needs the `$shortname` global to name Divi, and * Bricks' needs its running `Bricks\Theme` singleton, and GeneratePress's * needs its dynamic-CSS printer hooked on `wp_enqueue_scripts`, none of * which a loaded-but-idle stub provides, while Astra's is a bare * `function_exists()` that stays true once its stub is loaded — so Astra * is asked last. Among the strict ones the order is arbitrary. * * @var array */ protected static $settingsReaders = [ 'blocksy' => BlocksySettingsReader::class, 'kadence' => KadenceSettingsReader::class, 'divi' => DiviSettingsReader::class, 'bricks' => BricksSettingsReader::class, 'generatepress' => GeneratePressSettingsReader::class, 'astra' => AstraSettingsReader::class, ]; /** * The roles a theme settings reader may state. * * @var array */ protected static $settingsRoleKeys = [ 'surface', 'text', 'accent', 'border', 'button_bg', 'button_text', 'button_hover_bg', 'button_hover_text', ]; /** * Drop the request-level caches. * * Reading theme.json and resolving eleven roles is repeated work within a * request, so both are cached. Anything that changes what those reads would * return — switching theme, editing the palette, a filter registered after * a first read — has to clear them or it keeps seeing the old palette. * * @return void */ public static function clearCache(): void { self::$cachedPalette = null; self::$cachedRoles = null; self::$cachedVendorValues = null; self::$cachedSettingsRoles = null; } /** * The active theme's colour palette, normalised to hex. * * @return array List of ['slug' => ..., 'name' => ..., 'color' => ...]. */ public static function palette(): array { if (self::$cachedPalette !== null) { return self::$cachedPalette; } if (!function_exists('wp_get_global_settings')) { self::$cachedPalette = []; return self::$cachedPalette; } $raw = wp_get_global_settings(['color', 'palette']); self::$cachedPalette = is_array($raw) ? self::normalizePalette($raw) : []; return self::$cachedPalette; } /** * Flatten whatever shape wp_get_global_settings() returned. * * Depending on the WordPress version this is either a flat list or one * list per origin. The `default` origin is WordPress's own twelve-colour * palette — black, white and the vivid-* set — which every site has * whether or not its theme declares anything. Inheriting from it would * not be inheriting from the theme, and it would let a theme that * publishes nothing report a palette it does not have, so only the * theme's own colours and the user's customisations of them are read. * * @param array $raw * @return array */ protected static function normalizePalette(array $raw): array { $groups = []; if (isset($raw['default']) || isset($raw['theme']) || isset($raw['custom'])) { // `custom` comes last so a colour edited in the site editor wins // over the theme's declared value for the same slug. foreach (['theme', 'custom'] as $origin) { $originColors = Arr::get($raw, $origin, []); if (is_array($originColors) && $originColors) { $groups[] = $originColors; } } } else { $groups[] = $raw; } $entries = []; foreach ($groups as $colors) { foreach ($colors as $color) { $slug = Arr::get($color, 'slug', ''); if (!$slug) { continue; } $entries[$slug] = [ 'slug' => (string)$slug, 'name' => (string)Arr::get($color, 'name', $slug), 'color' => (string)Arr::get($color, 'color', ''), ]; } } /** * Filter the theme palette offered for colour inheritance. * * @param array $entries List of ['slug', 'name', 'color']. */ $entries = apply_filters('fluent_cart/theme/palette', array_values($entries)); return self::usableEntries($entries); } /** * Keep only the entries that name a slug and a colour we can actually read. * * theme.json is not restricted to hex, and several popular themes publish * their palette as `var(--theme-colour-0)` references that cannot be * resolved server-side. Those are dropped rather than guessed: an * unresolvable value handed on as if it were a colour would be mixed into * the derived tones, and a muted text colour mixed from nothing collapses * to the surface it sits on — invisible text rather than an obvious error. * * Applied after the filter as well as before it, because a filter is just * another source of values and gets no more trust than theme.json does. * * @param mixed $entries * @return array */ protected static function usableEntries($entries): array { if (!is_array($entries)) { return []; } $usable = []; foreach ($entries as $entry) { if (!is_array($entry)) { continue; } $slug = Arr::get($entry, 'slug', ''); $raw = (string)Arr::get($entry, 'color', ''); $hex = ColorMath::hex($raw); // A literal colour is preferred because it can also be mixed and // measured. A reference cannot be resolved here, but the browser // resolves it perfectly well, so it is kept as something we can // write — and when it carries a hex fallback, that fallback is the // colour it can be measured as too. $value = $hex !== '' ? $hex : self::safeReference($raw); // A bare reference to a property the active vendor itself declares // is only unreadable to an outsider: the vendor prints it from its // own settings, and those are one function call away. Attaching // that value as the fallback turns the reference into the // measurable shape — the browser still follows the live property, // the fallback is only what it is measured as here. if ($hex === '' && $value !== '' && self::measurable($value) === '') { $property = substr($value, 4, -1); $vendorHex = (string)Arr::get(self::vendorValues(), $property, ''); if ($vendorHex !== '') { $value = 'var(' . $property . ', ' . $vendorHex . ')'; } } if (!$slug || $value === '') { continue; } $usable[(string)$slug] = [ 'slug' => (string)$slug, 'name' => (string)Arr::get($entry, 'name', $slug), 'color' => self::measurable($value), 'value' => $value, ]; } return array_values($usable); } /** * The current values of the properties the active vendor declares, keyed * by property name. * * Each mapping reads the same source the vendor prints the property from, * so customisations are included — the customiser saves into the very * option being read. Astra prints `--ast-global-color-N` from * `astra_get_option('global-color-palette')` (its * `generate_global_palette_style()`), and Kadence prints * `--global-paletteN` from `kadence()->palette_option('paletteN')` (its * styles component; see KadenceSettingsReader::paletteValues()). * Blocksy's editor palette already ships with hex fallbacks, but its Customizer settings point at the bare * `--theme-palette-color-N`, so its palette is read too, from * `blocksy_manager()->colors->get_color_palette()` (the list it prints * the properties from; see BlocksySettingsReader::paletteValues()). * Bricks prints `--bricks-color-{id}` from its colour palette (or the * property a palette colour's raw value names; see * BricksSettingsReader::paletteValues()). GeneratePress prints `--{slug}` * on :root from its Global Colors and publishes them to the editor * palette as bare `var(--{slug})` (see * GeneratePressSettingsReader::paletteValues()). * * The values are only ever used as the fallback half of `var(--x, #hex)`, * so a wrong or stale answer cannot repaint anything — the browser keeps * resolving the live property — it can only mis-measure, which is where * resolve() refusing an unmeasurable button still protects the store. * * @return array Property => hex. */ protected static function vendorValues(): array { if (self::$cachedVendorValues !== null) { return self::$cachedVendorValues; } $values = []; if (function_exists('astra_get_option')) { $palette = astra_get_option('global-color-palette'); $colors = is_array($palette) ? Arr::get($palette, 'palette', []) : []; foreach ((array)$colors as $index => $color) { $values['--ast-global-color-' . $index] = (string)$color; } } foreach (BlocksySettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } foreach (KadenceSettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } foreach (BricksSettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } foreach (GeneratePressSettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } /** * Filter the vendor-declared custom property values used to measure * bare palette references, keyed by property name (`--x` => `#hex`). * * @param array $values Property => colour. */ $values = apply_filters('fluent_cart/theme/vendor_values', $values); // A filter is just another source of values: only a real property // name paired with a real hex survives. Kadence's palette10 can be an // oklch() expression, which this drops too — an expression cannot be // measured, and it must never ride into a declaration as a fallback. $clean = []; if (is_array($values)) { foreach ($values as $property => $color) { $colorHex = ColorMath::hex((string)$color); if ($colorHex !== '' && preg_match('/^--[A-Za-z0-9_-]+$/', (string)$property)) { $clean[(string)$property] = $colorHex; } } } return self::$cachedVendorValues = $clean; } /** * Accept a bare custom-property reference, and nothing more. * * Themes built on the page-builder stack — Astra, Kadence, GeneratePress — * publish their palette as `var(--ast-global-color-0)` rather than as a * literal, because the real value lives in their own settings and is * emitted as a custom property at runtime. Refusing those makes theme * inheritance do nothing on a large share of real stores. * * Only the single-argument form is accepted: no fallback expression, no * nesting, no parentheses beyond the one pair. This string is written into * a declaration on every storefront page, so the grammar is kept narrow * enough that nothing else can ride along inside it. * * @param string $value * @return string The normalised reference, or '' when it is not one. */ protected static function safeReference($value): string { $value = trim((string)$value); if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*\)$/', $value, $matches)) { return 'var(' . $matches[1] . ')'; } // One fallback shape is allowed, and only one: a hex colour. Customify // publishes its whole palette as `var(--customify-primary, #0e7c7b)` — // the reference follows the customiser live, and the fallback is the // one kind of fallback that is itself checkable. `red`, expressions and // nested var() stay refused. if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { return 'var(' . $matches[1] . ', ' . strtolower($matches[2]) . ')'; } return ''; } /** * The colour a value can actually be measured as. * * A hex is itself. A reference with a hex fallback is measured as the * fallback — the theme shipped it as the value to use when the property is * missing, which makes it the theme's own best answer for what the colour * is. A bare reference measures as nothing. * * @param string $value * @return string Hex, or ''. */ public static function measurable($value): string { $value = (string)$value; $hex = ColorMath::hex($value); if ($hex !== '') { return $hex; } if (preg_match('/^var\(\s*--[A-Za-z0-9_-]+\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { return strtolower($matches[1]); } return ''; } /** * Normalise a colour a theme setting states into a value we can write. * * A hex is itself, lowercased. A custom-property reference stays a live * reference; a bare one gains the vendor's current value as its hex * fallback (see vendorValues()), so the browser still follows the * property while PHP measures the fallback. Anything else — rgba(), * named colours, expressions — is refused: the result is written into a * declaration on every storefront page and must pass * FrontendTheme::sanitizeDeclarationValue(). * * @param mixed $raw * @return string Hex, `var(--x)`, `var(--x, #hex)`, or ''. */ public static function settingValue($raw): string { if (!is_string($raw)) { return ''; } $raw = trim($raw); if (preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $raw)) { return ColorMath::hex($raw); } $reference = self::safeReference($raw); if ($reference === '' || self::measurable($reference) !== '') { return $reference; } $property = substr($reference, 4, -1); $vendorHex = (string)Arr::get(self::vendorValues(), $property, ''); return $vendorHex !== '' ? 'var(' . $property . ', ' . $vendorHex . ')' : $reference; } /** * The colours the active theme's own settings state, by role. * * Palette slots say which colours a theme offers; its settings say which * one the owner put where. A reader for the active theme (see * Readers\ThemeSettingsReader) reports what the owner set — Astra's, * Blocksy's, Kadence's, Divi's, Bricks' or GeneratePress's Accent, Body Text, Content Background, Borders * and Button colours — and those outrank the palette-slot guesses in anchors() and resolve(). * Only the site editor's own button (buttonGlobals()) outranks them. * * Roles: surface, text, accent, border, button_bg, button_text, * button_hover_bg, button_hover_text. A role the theme does not state is * absent, and is derived exactly as it would be without a reader. * * @return array Role => hex or reference. */ public static function settingsRoles(): array { if (self::$cachedSettingsRoles !== null) { return self::$cachedSettingsRoles; } $roles = []; $theme = ''; foreach (self::$settingsReaders as $name => $reader) { if ($reader::applies()) { $theme = $name; $roles = $reader::roles(); break; } } /** * Filter the colours the active theme's settings state, by role. * * Runs whether or not a built-in reader applied, so a theme FluentCart * does not read can be supplied here; return [] to turn the reader off * and fall back to palette slots. Values must be a hex or a * `var(--x)` / `var(--x, #hex)` reference — anything else is dropped, * and a bare reference to a vendor property gains its hex fallback. * * @param array $roles Role => colour. Keys: surface, text, accent, * border, button_bg, button_text, * button_hover_bg, button_hover_text. * @param array $context ['theme' => reader name ('blocksy', 'kadence', 'divi', 'bricks', 'generatepress', 'astra'), or '' when * no built-in reader applies]. */ $roles = apply_filters('fluent_cart/theme/settings_roles', $roles, [ 'theme' => $theme, ]); // A filter is just another source of values: only known roles with a // writable colour survive. $clean = []; if (is_array($roles)) { foreach (self::$settingsRoleKeys as $role) { $value = self::settingValue(Arr::get($roles, $role, '')); if ($value !== '') { $clean[$role] = $value; } } } return self::$cachedSettingsRoles = $clean; } /** * Whether the active theme gave us anything usable. * * @return bool */ public static function available(): bool { return count(self::palette()) > 0; } /** * Whether the theme told us anything at all to inherit from. * * A theme can publish a palette we cannot read — several popular ones * declare theirs as `var(--theme-colour-0)` references — and it can set a * global background and text colour without publishing a palette. A site * can also state nothing but its button pair (Styles → Buttons in the * site editor), and that is still an explicit configuration to wear. * Only when all of them come back empty is there genuinely nothing to * inherit, and in that case inheriting must stay out of the way rather * than write FluentCart's own colours back to the page and call them the * theme's. * * @return bool */ public static function hasUsableSource(): bool { if (self::available()) { return true; } $globals = self::globalColors(); if (Arr::get($globals, 'background', '') !== '' || Arr::get($globals, 'text', '') !== '') { return true; } if (self::settingsRoles()) { return true; } return Arr::get(self::buttonGlobals(), 'background', '') !== ''; } /** * Look up one palette colour by slug. * * @param string $slug * @return string Hex, or '' when the slug is unknown. */ public static function color(string $slug): string { return self::lookup($slug, 'color'); } /** * What one palette slug resolves to for writing into CSS. * * Unlike color(), this can be a custom-property reference — use it when * emitting a declaration, and color() when the value has to be reasoned * about. * * @param string $slug * @return string Hex, a var() reference, or '' when the slug is unknown. */ public static function value(string $slug): string { return self::lookup($slug, 'value'); } /** * Read one field off a palette entry. * * @param string $slug * @param string $field * @return string */ protected static function lookup(string $slug, string $field): string { if ($slug === '') { return ''; } foreach (self::palette() as $entry) { if (Arr::get($entry, 'slug') === $slug) { return (string)Arr::get($entry, $field, ''); } } return ''; } /** * The theme's global background and text colours, when it sets them. * * @return array ['background' => hex, 'text' => hex]; either may be ''. */ public static function globalColors(): array { $colors = ['background' => '', 'text' => '']; if (!function_exists('wp_get_global_styles')) { return $colors; } $styles = wp_get_global_styles(['color']); if (!is_array($styles)) { return $colors; } $colors['background'] = self::resolveReference(Arr::get($styles, 'background', '')); $colors['text'] = self::resolveReference(Arr::get($styles, 'text', '')); return $colors; } /** * The button pair the theme declares and the site editor edits. * * Styles → Buttons in the site editor saves to * `styles.elements.button.color`, and block themes ship their own pair * there in theme.json — the most explicit statement either can make * about the buttons. The two origins merge per property, the owner's * edits over the theme's, which is exactly how WordPress itself paints * the button. Core's origin is left out on purpose: WordPress ships a * default button colour (#32373c) for every site, and counting that as * "the theme said something" would mean no theme could ever stand aside. * Backgrounds usually arrive as a preset reference * (`var:preset|color|contrast`), which resolves through the palette the * same way the page pair's do. * * The `:hover` pair (`styles.elements.button.:hover.color`) is read the * same way, into `hover_background` / `hover_text`. * * @return array ['background' => hex, 'text' => hex, 'hover_background' => hex, * 'hover_text' => hex]; any may be ''. */ public static function buttonGlobals(): array { $colors = ['background' => '', 'text' => '', 'hover_background' => '', 'hover_text' => '']; if (!class_exists('WP_Theme_JSON_Resolver')) { return $colors; } foreach (['get_theme_data', 'get_user_data'] as $origin) { if (!method_exists('WP_Theme_JSON_Resolver', $origin)) { continue; } $data = \WP_Theme_JSON_Resolver::$origin(); if (!is_object($data) || !method_exists($data, 'get_raw_data')) { continue; } $raw = (array)$data->get_raw_data(); $pairs = [ '' => Arr::get($raw, 'styles.elements.button.color', []), 'hover_' => Arr::get($raw, 'styles.elements.button.:hover.color', []), ]; foreach ($pairs as $prefix => $pair) { foreach (['background', 'text'] as $half) { $value = self::resolveReference((string)Arr::get((array)$pair, $half, '')); if ($value !== '') { $colors[$prefix . $half] = $value; } } } } return $colors; } /** * Resolve a theme.json colour, which may be a preset reference rather * than a literal colour. * * @param string $value * @return string Hex, or ''. */ protected static function resolveReference($value): string { $value = trim((string)$value); if ($value === '') { return ''; } if (preg_match('/var:preset\|color\|([\w-]+)/', $value, $matches)) { return self::color($matches[1]); } if (preg_match('/var\(\s*--wp--preset--color--([\w-]+)/', $value, $matches)) { return self::color($matches[1]); } return ColorMath::hex($value); } /** * Which palette slug each anchor role resolves to. * * @return array Role => palette slug, '' when nothing matched. */ public static function anchorMap(): array { $map = []; foreach (self::$anchorCandidates as $role => $slugs) { $map[$role] = ''; foreach ($slugs as $slug) { // value(), not color(): a slug the theme publishes only as a // reference still counts as a colour the theme offers. if (self::value($slug) !== '') { $map[$role] = $slug; break; } } } /** * Filter which theme palette slug drives each anchor role. * * @param array $map Role => palette slug. */ return apply_filters('fluent_cart/theme/anchor_map', $map); } /** * The four anchors, exactly as far as the theme could be read. * * An anchor the theme did not supply comes back empty rather than standing * in FluentCart's own colour for it. The stylesheet that uses the property * already carries that colour as its `var(--fct-x, )` fallback, * so substituting it here would only mean writing the same value twice — * and writing it under the active theme's name, which is how a store on a * theme nothing could be read from came to report itself as inheriting * while wearing FluentCart's palette. * * @return array Role => hex, a custom-property reference, or '' when the * theme said nothing about it. */ public static function anchors(): array { $map = self::anchorMap(); $globals = self::globalColors(); $settings = self::settingsRoles(); // Surface and body text resolve as a PAIR from one source, never mixed // from two. Global styles outrank the palette — the palette lists the // AVAILABLE colours, global styles say what the body actually RENDERS, // and on Twenty Twenty-Five's dark style variation those disagree — // but only when global styles supply BOTH halves. A site that sets // only its text (light, say, over a CSS-painted dark background) must // not have that fragment mixed onto the palette's guessed white // surface: that is light text printed onto a light panel, and the // stylesheets' paired fallbacks never fire because both values exist. $gsSurface = (string)Arr::get($globals, 'background', ''); $gsText = (string)Arr::get($globals, 'text', ''); $setSurface = (string)Arr::get($settings, 'surface', ''); if ($gsSurface !== '' && $gsText !== '') { // The rendered pair. $surface = $gsSurface; $text = $gsText; } elseif ($setSurface !== '') { // The pair the theme's settings state (Astra's Content Background // and Body Text). The same pair law: a lone stated text is never // mixed onto a guessed surface. A lone surface takes the palette's // own text when it reads there (4.5:1) — Astra saves Body Text // empty and still means its text swatch — and only an unreadable // one is replaced by a measured partner. $surface = $setSurface; $text = (string)Arr::get($settings, 'text', ''); $surfaceHex = self::measurable($surface); if ($text === '' && $surfaceHex !== '') { $paletteText = self::value(Arr::get($map, 'text', '')); $paletteTextHex = self::measurable($paletteText); $text = $paletteTextHex !== '' && ColorMath::contrast($surfaceHex, $paletteTextHex) >= 4.5 ? $paletteText : ColorMath::readableOn($surfaceHex, '#F3F4F6', '#2F3448'); } } else { // The published pair. A lone global-styles fragment is dropped // rather than paired with a guess. $surface = self::value(Arr::get($map, 'surface', '')); $text = self::value(Arr::get($map, 'text', '')); if ($text === '' && ColorMath::hex($surface) !== '') { // A lone measurable surface derives its partner by contrast, // the same way button text does — leaving it unwritten would // drop the stylesheets' dark fallback text onto a surface // that may itself be dark. $text = ColorMath::readableOn($surface, '#F3F4F6', '#2F3448'); } } $accent = (string)Arr::get($settings, 'accent', ''); if ($accent === '') { $accent = self::value(Arr::get($map, 'accent', '')); } // The button follows the same law as the page pair: what the owner // set in the site editor (Styles → Buttons) outranks every guess the // palette could offer — but only led by its background. A background // brings its own text partner, or gets one measured against it in // resolve(); a lone text fragment is dropped rather than printed onto // a background it was never chosen for. $button = self::buttonGlobals(); $buttonText = ''; if ($button['background'] !== '') { $buttonBg = $button['background']; $buttonText = $button['text']; } elseif (Arr::get($settings, 'button_bg', '') !== '') { // Next, the button the theme's settings state, with its partner. $buttonBg = (string)$settings['button_bg']; $buttonText = (string)Arr::get($settings, 'button_text', ''); } else { $buttonBg = self::value(Arr::get($map, 'button_bg', '')); if ($buttonBg === '') { // Not a default — the theme's own accent, which is what a // theme that names one button colour almost always means by it. $buttonBg = $accent; } } return [ 'surface' => $surface, 'text' => $text, 'accent' => $accent, 'button_bg' => $buttonBg, 'button_text' => $buttonText, ]; } /** * Resolve every semantic role the theme can actually supply. * * The first block is what the theme said. The second is derived from it — * no theme publishes a hairline border or a placeholder tone, so those are * mixed out of the body text and the surface. * * Deriving needs two real colours to mix between. A reference cannot be * read here (the browser resolves it on the page, where we are not), and an * anchor the theme never supplied is not a colour at all, so in both cases * the derived roles come back empty and nothing is written for them. The * stylesheet's own fallback is then what applies, which is the same colour * it would have been given — arrived at once instead of twice. * * @return array Role => hex, a reference, or '' when it could not be * resolved. */ public static function resolve(): array { if (self::$cachedRoles !== null) { return self::$cachedRoles; } $anchors = self::anchors(); $surface = (string)Arr::get($anchors, 'surface', ''); $text = (string)Arr::get($anchors, 'text', ''); $buttonBg = (string)Arr::get($anchors, 'button_bg', ''); // Measured, not taken literally: a reference with a hex fallback mixes // as the fallback the theme shipped, so a Customify-style palette // derives instead of standing down. $textHex = self::measurable($text); $surfaceHex = self::measurable($surface); $mixable = $textHex !== '' && $surfaceHex !== ''; $derived = $mixable ? [ 'surface_alt' => ColorMath::mix($textHex, $surfaceHex, 4), 'surface_mute' => ColorMath::mix($textHex, $surfaceHex, 8), 'divider' => ColorMath::mix($textHex, $surfaceHex, 10), 'border' => ColorMath::mix($textHex, $surfaceHex, 18), 'text_placeholder' => ColorMath::mix($textHex, $surfaceHex, 45), // Muted text is still text: it moves toward the body text only // as far as it must to read (4.5:1). A mid-grey body text // (Divi's #666666) otherwise mixed down to 2.92:1. 'text_muted' => ColorMath::readableMix($textHex, $surfaceHex, 68), ] : [ 'surface_alt' => '', 'surface_mute' => '', 'divider' => '', 'border' => '', 'text_placeholder' => '', 'text_muted' => '', ]; // A border the theme's settings state is the border (Astra's Borders); // only an unstated one is mixed. $settings = self::settingsRoles(); if (Arr::get($settings, 'border', '') !== '') { $derived['border'] = (string)$settings['border']; } // Contrast needs a real colour to measure against for the same reason. // And the button is owned as a pair or not at all: a background whose // value cannot be measured (a bare reference — the theme resolves it on // the page, and the store owner can recolour it to anything) is one no // readable text can be paired with here, so neither half is written and // the stylesheets' own paired fallback styles the button instead. Text // the owner chose alongside the background (the site editor's button // pair, or a theme settings reader's) is worn as given unless it cannot // be read on it (below 3:1); a missing or unreadable partner is measured. $buttonBgHex = self::measurable($buttonBg); $ownText = self::statedTextOn((string)Arr::get($anchors, 'button_text', ''), $buttonBgHex); if ($buttonBgHex !== '') { $derived['button_text'] = $ownText !== '' ? $ownText : ColorMath::readableText($buttonBgHex); } else { $anchors['button_bg'] = ''; $derived['button_text'] = ''; } // The hover follows the button it belongs to: unwritten when the button // is. The site editor's `:hover` pair outranks any guess; otherwise the // button colour moves away from itself — darker for a light button, // lighter for a dark one. Hover text given with it is worn as given; // otherwise the resting text carries over while it still reads (WCAG // 4.5:1), and a partner is measured when it does not. $derived['button_hover_bg'] = ''; $derived['button_hover_text'] = ''; if ($buttonBgHex !== '') { $stated = self::buttonGlobals(); $hoverBg = $stated['hover_background']; $hoverText = $stated['hover_text']; // The theme settings' hover belongs to the theme settings' button: // it is only used when that button is the one being worn, never // paired onto a button the site editor states. $settingsButton = $stated['background'] === '' && Arr::get($settings, 'button_bg', '') !== ''; if ($hoverBg === '' && $settingsButton && Arr::get($settings, 'button_hover_bg', '') !== '') { $hoverBg = (string)$settings['button_hover_bg']; if ($hoverText === '') { $hoverText = (string)Arr::get($settings, 'button_hover_text', ''); } } if ($hoverBg === '') { $hoverBg = ColorMath::shiftFromItself($buttonBgHex, 12); } $hoverBgHex = self::measurable($hoverBg); $hoverText = self::statedTextOn($hoverText, $hoverBgHex); if ($hoverText === '' && $hoverBgHex !== '') { $hoverText = self::hoverTextFor($hoverBgHex, (string)$derived['button_text']); } $derived['button_hover_bg'] = $hoverBg; $derived['button_hover_text'] = $hoverText; } // The outline secondary button is FluentCart's own invention, and its // outline is the hierarchy: it always keeps the page surface as its // background. Wearing the theme's stated pair outright painted BOTH // CTAs identically (Twenty Twenty-Five states black-on-white, so Add // to Cart went black beside a black Buy Now). Instead the label // borrows a half of the pair AS WORN by the primary button (stated // text, or the measured partner of a lone stated background) — // whichever half reads better on the page surface, and only when // that half actually reads (WCAG 4.5:1); otherwise the page's own // text stays, since a borrowed label that cannot be read matches // nothing worth matching. Measurement uses the surface's hex, which // for a reference is its fallback — the theme's own best answer, the // same trust every derived tone already extends (spec 59). $derived['secondary_button_bg'] = $surface; $derived['secondary_button_text'] = $text; if (self::buttonGlobals()['background'] !== '' && $buttonBgHex !== '' && $surfaceHex !== '') { $pairText = (string)$derived['button_text']; $label = ColorMath::contrast($surfaceHex, $pairText) >= ColorMath::contrast($surfaceHex, $buttonBgHex) ? $pairText : $buttonBgHex; if (ColorMath::contrast($surfaceHex, $label) >= 4.5) { $derived['secondary_button_text'] = $label; } } /** * Filter the resolved semantic role colours. * * @param array $roles Role => hex. */ self::$cachedRoles = apply_filters('fluent_cart/theme/roles', array_merge($anchors, $derived)); return self::$cachedRoles; } /** * A text colour a theme or owner stated for a background, if it can be worn. * * Stated text is a choice and is worn as given — including brand pairs * just under AA (white on #ff5500 is 3.21:1). Only one that cannot be read * on its background (below 3:1, the large-text floor) is refused, so a * measured partner takes its place. Twenty Twenty-Five's site-editor pair * #111111 on #503aa8 (2.26:1) is the case this catches. A text or a * background that cannot be measured here is not second-guessed. * * @param string $text The stated text, or ''. * @param string $backgroundHex The measured background, or ''. * @return string The text, or '' when it must be replaced. */ public static function statedTextOn(string $text, string $backgroundHex): string { if ($text === '' || $backgroundHex === '') { return $text; } $textHex = self::measurable($text); if ($textHex === '') { return $text; } return ColorMath::contrast($backgroundHex, $textHex) >= 3 ? $text : ''; } /** * The text for a hover background nobody gave a text: the button's * resting text carries over while it still reads (WCAG 4.5:1), and a * partner is measured when it does not. * * @param string $hoverBgHex * @param string $restingText * @return string */ public static function hoverTextFor(string $hoverBgHex, string $restingText): string { return ColorMath::contrast($hoverBgHex, $restingText) >= 4.5 ? $restingText : ColorMath::readableText($hoverBgHex); } /** * A one-line description of what the active theme offers, shown under the * inherit option so the store owner knows whether it is worth picking. * * @return string */ public static function sourceLabel(): string { $count = count(self::palette()); if (!$count) { return __('The active theme does not publish a colour palette, so inheriting would fall back to FluentCart\'s own colours.', 'fluent-cart'); } $theme = wp_get_theme(); return sprintf( /* translators: 1: active theme name, 2: number of palette colours the theme publishes */ _n('%1$s provides %2$d palette colour.', '%1$s provides %2$d palette colours.', $count, 'fluent-cart'), $theme->get('Name'), $count ); } }