for FOIC prevention * - localStorage-based preference persistence * * @since 1.5.0 */ class DarkModeService { use SingletonTrait; /** * LocalStorage key for storing user preference * * @since 1.5.0 */ const STORAGE_KEY = 'brandy-color-scheme'; /** * Whether dark mode has been initialized * * @since 1.5.0 * @var bool */ private $initialized = false; /** * Constructor - defers hook registration until niche is known * * Uses lazy initialization pattern because: * - Niches register at 'init' priority 9 * - DarkModeService is instantiated in ThemeInitialize * - We need to check niche support before registering hooks * * @since 1.5.0 */ protected function __construct() { // Defer actual setup until init when niche is known self::maybe_initialize(); } /** * Check if current niche supports dark mode * * @since 1.5.0 * @return bool True if dark mode is supported */ public static function is_supported(): bool { return function_exists( 'brandy_niche_supports' ) && brandy_niche_supports( 'dark-mode' ); } /** * Initialize dark mode if supported by current niche * * Registers hooks for dark mode functionality: * - wp_theme_json_data_default: WordPress core default palette * - wp_theme_json_data_theme: Theme-defined colors * - wp_theme_json_data_user: User customizations (Global Styles) * * @since 1.5.0 */ public function maybe_initialize() { // Only initialize if current niche supports dark mode if ( ! self::is_supported() ) { return; } if ( $this->initialized ) { return; } $this->initialized = true; add_action( 'wp_head', array( $this, 'output_head_script' ), 1 ); // Filter all theme.json data sources for complete dark mode coverage add_filter( 'wp_theme_json_data_default', array( $this, 'filter_theme_json_colors' ) ); add_filter( 'wp_theme_json_data_theme', array( $this, 'filter_theme_json_colors' ) ); add_filter( 'wp_theme_json_data_user', array( $this, 'filter_theme_json_colors' ) ); } /** * Filter theme.json to wrap all hex colors with light-dark() * * Processes: * - settings.color.palette (color property) * - settings.custom (all nested hex values) * - styles (all nested hex values) * * @since 1.5.0 * @param WP_Theme_JSON_Data $theme_json Theme JSON data object. * @return WP_Theme_JSON_Data Modified theme JSON data. */ public function filter_theme_json_colors( $theme_json ) { $data = $theme_json->get_data(); // Process theme palette (special handling for 'color' key) if ( ! empty( $data['settings']['color']['palette']['theme'] ) ) { $data['settings']['color']['palette']['theme'] = $this->wrap_palette_colors( $data['settings']['color']['palette']['theme'] ); } if ( ! empty( $data['settings']['color']['palette']['default'] ) ) { $data['settings']['color']['palette']['default'] = $this->wrap_palette_colors( $data['settings']['color']['palette']['default'] ); } // Process custom settings recursively if ( ! empty( $data['settings']['custom'] ) ) { $data['settings']['custom'] = $this->wrap_colors_recursive( $data['settings']['custom'] ); } // Process styles recursively if ( ! empty( $data['styles'] ) ) { $data['styles'] = $this->wrap_colors_recursive( $data['styles'], array( 'css' ) ); } // Ensure color-scheme is set in styles if ( ! isset( $data['styles']['css'] ) ) { $data['styles']['css'] = ''; } if ( false === strpos( $data['styles']['css'], 'color-scheme' ) ) { $data['styles']['css'] = ':root { color-scheme: light dark; } ' . $data['styles']['css']; } return $theme_json->update_with( $data ); } /** * Recursively wrap hex colors in nested arrays * * @since 1.5.0 * @param mixed $data Data to process (array or string). * @param array $skip_keys Keys to skip processing (e.g., 'css' for raw CSS strings). * @return mixed Processed data with light-dark() wrapped colors. */ private function wrap_colors_recursive( $data, $skip_keys = array() ) { if ( ! is_array( $data ) ) { return $this->maybe_wrap_hex_color( $data ); } foreach ( $data as $key => &$value ) { // Skip specified keys if ( in_array( $key, $skip_keys, true ) ) { continue; } if ( is_array( $value ) ) { $value = $this->wrap_colors_recursive( $value, $skip_keys ); } else { $value = $this->maybe_wrap_hex_color( $value ); } } return $data; } /** * Wrap a single hex color value with light-dark() if applicable * * @since 1.5.0 * @param mixed $value Value to check and potentially wrap. * @return mixed Original value or wrapped color. */ private function maybe_wrap_hex_color( $value ) { if ( ! is_string( $value ) ) { return $value; } // Skip if already wrapped if ( false !== strpos( $value, 'light-dark(' ) ) { return $value; } // Skip if not a hex color if ( ! preg_match( '/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/', $value ) ) { return $value; } // Skip transparent colors (8-char hex with alpha < 50%) if ( 9 === strlen( $value ) ) { $alpha = hexdec( substr( $value, 7, 2 ) ); if ( $alpha < 128 ) { return $value; } } return self::wrap_light_dark( $value ); } /** * Wrap palette colors with light-dark() function * * @since 1.5.0 * @param array $palette Color palette array. * @return array Modified palette with light-dark() values. */ private function wrap_palette_colors( $palette ) { foreach ( $palette as &$color_item ) { if ( empty( $color_item['color'] ) ) { continue; } $color_item['color'] = $this->maybe_wrap_hex_color( $color_item['color'] ); } return $palette; } /** * Generate dark variant from light color using OKLCH color space * * OKLCH provides perceptually uniform lightness, meaning: * - Lightness values correlate with human perception * - No hue/saturation drift when adjusting brightness * - Consistent results across different hues (unlike HSL) * * Algorithm: * 1. Convert hex to OKLCH * 2. Map lightness using a curve that compresses toward middle values * 3. Reduce chroma by 15% to prevent "vibration" on dark backgrounds * 4. Convert back to hex with gamut mapping * * @since 1.5.0 * @param string $light_hex Light color hex code * @return string Dark color hex code */ public static function get_dark_color( $light_hex ) { // Normalize hex format $light_hex = ltrim( $light_hex, '#' ); $light_hex = '#' . $light_hex; // Convert to OKLCH for perceptually uniform manipulation $oklch = Helpers::hex_to_oklch( $light_hex ); if ( ! $oklch ) { // Fallback for invalid colors return '#1a1a1a'; } $light_l = $oklch['l']; // Special handling for achromatic colors (grays, white, black) if ( $oklch['c'] < 0.02 ) { // Pure white → dark gray for backgrounds if ( $light_l > 0.95 ) { return '#1a1a1a'; } // Pure black → light gray for text if ( $light_l < 0.05 ) { return '#e5e5e5'; } // Grays: invert with compression toward middle $dark_l = 1 - $light_l; $dark_l = 0.2 + ( $dark_l * 0.6 ); // Map to 0.2-0.8 range return Helpers::oklch_to_hex( $dark_l, 0, 0 ); } // For chromatic colors: use curve-based lightness mapping // This keeps colors visible and vibrant in dark mode // Map light colors (L > 0.7) → darker mid-tones (0.3-0.5) // Map dark colors (L < 0.4) → lighter mid-tones (0.5-0.7) // Map mid-tones → slight shift toward opposite if ( $light_l > 0.7 ) { // Very light colors: map to 0.35-0.50 range $dark_l = 0.50 - ( ( $light_l - 0.7 ) / 0.3 ) * 0.15; } elseif ( $light_l < 0.4 ) { // Dark colors: map to 0.55-0.70 range $dark_l = 0.55 + ( ( 0.4 - $light_l ) / 0.4 ) * 0.15; } else { // Mid-tones: gentle inversion around 0.5 $dark_l = 1 - $light_l; } // Clamp to reasonable range $dark_l = max( 0.25, min( 0.75, $dark_l ) ); // Reduce chroma by 15% to prevent color "vibration" on dark backgrounds // (Material Design recommendation for dark theme colors) $dark_c = $oklch['c'] * 0.85; // Keep hue unchanged - preserves color identity $dark_h = $oklch['h']; // Convert back to hex (includes gamut mapping) return Helpers::oklch_to_hex( $dark_l, $dark_c, $dark_h ); } /** * Wrap colors in light-dark() CSS function * * @since 1.5.0 * @param string $light_hex Light mode color * @param string $dark_hex Dark mode color (auto-generated if null) * @return string CSS light-dark() value */ public static function wrap_light_dark( $light_hex, $dark_hex = null ) { if ( null === $dark_hex ) { $dark_hex = self::get_dark_color( $light_hex ); } return sprintf( 'light-dark(%s, %s)', $light_hex, $dark_hex ); } /** * Process global settings array to wrap colors with light-dark() * Used for data that bypasses wp_theme_json_data_* filters (e.g., wp_get_global_settings) * * @since 1.5.0 * @param array $settings Global settings array from wp_get_global_settings(). * @return array Processed settings with light-dark() wrapped colors. */ public static function process_global_settings( $settings ) { // Skip processing if dark mode not supported if ( ! self::is_supported() ) { return $settings; } $instance = self::get_instance(); // Process color palette if ( ! empty( $settings['color']['palette']['theme'] ) ) { $settings['color']['palette']['theme'] = $instance->wrap_palette_colors( $settings['color']['palette']['theme'] ); } if ( ! empty( $settings['color']['palette']['default'] ) ) { $settings['color']['palette']['default'] = $instance->wrap_palette_colors( $settings['color']['palette']['default'] ); } // Process custom settings if ( ! empty( $settings['custom'] ) ) { $settings['custom'] = $instance->wrap_colors_recursive( $settings['custom'] ); } return $settings; } /** * Output blocking script in head to prevent FOIC * MUST run synchronously before CSS parsing * * This script: * 1. Checks localStorage for user preference * 2. Falls back to OS preference via matchMedia * 3. Sets data-theme attribute and colorScheme before any rendering * * @since 1.5.0 */ public function output_head_script() { $storage_key = self::STORAGE_KEY; ?>