| Server IP : 104.21.20.47 / Your IP : 104.23.243.204 Web Server : Microsoft-IIS/7.5 System : Windows NT WIN-HN0MTSF2AF1 6.1 build 7601 (Windows Server 2008 R2 Enterprise Edition Service Pack 1) AMD64 User : IWPD_11(bayt-altogg) ( 0) PHP Version : 8.2.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : C:/Inetpub/vhosts/bayt-altoggar.com/httpdocs/wp-content/themes/customify/inc/ |
Upload File : |
<?php
/**
* Customify Colors palette — :root CSS variable emitter.
*
* Emits a `:root` block declaring the 6 slot vars and ~10 derived vars used by
* the new Colors panel. Derived vars use a static (PHP-precomputed) fallback
* plus an `@supports (color-mix)` refinement so modern browsers can live-mix
* while older browsers still see a sensible static color.
*
* Override mechanism: a derived var is only emitted as "computed" when its
* corresponding legacy theme_mod key has NO saved value. If the legacy key is
* saved, the derived var is locked to the saved value so 30K+ legacy sites
* render byte-identical to before.
*
* @package Customify
*/
defined( 'ABSPATH' ) || exit;
// ──────────────────────────────────────────────────────────────────
// Color math helpers (sRGB-space; good enough for fallback hex).
// ──────────────────────────────────────────────────────────────────
if ( ! function_exists( 'customify_color_normalize_hex' ) ) {
/**
* Validate + normalize a color value. Accepts:
* • 3- or 6-char hex (with or without leading #) → returned as #rrggbb
* • rgb(r,g,b) and rgba(r,g,b,a) → returned as-is (alpha preserved)
*
* Returns $fallback for anything else (empty string, invalid chars,
* named colors, var(), hsl, etc.).
*
* Name kept as `normalize_hex` for backcompat — it's been the slot
* reader on every install since Phase 2 launched. The rgba support
* added later (Phase 2.10) lets the WP color picker's alpha slider
* round-trip correctly: when user picks a transparent brand color,
* the rgba string survives the slot read so :root --customify-primary
* gets the actual rgba value (not a hex fallback) — and downstream
* helpers (hex_to_rgb, relative_luminance, pick_on) handle rgba by
* stripping the alpha for luminance math.
*
* Use this on every read of a user-saved color value before feeding it into
* CSS output or math helpers — wp-cli and external code can bypass the
* Customizer sanitize_callback and write raw values.
*/
function customify_color_normalize_hex( $value, $fallback = '#000000' ) {
if ( ! is_string( $value ) ) {
return $fallback;
}
$value = trim( $value );
// Accept rgb()/rgba() syntax — returned verbatim (preserves alpha).
// Anchor the trailing `)` so partial inputs like `rgba(255,255,255`
// (cut off) don't pass the validator. composite_over already does
// this; keeping the regexes consistent across helpers.
if ( preg_match( '/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*[\d.]+)?\s*\)$/i', $value ) ) {
return $value;
}
$hex = ltrim( $value, '#' );
if ( strlen( $hex ) === 3 && ctype_xdigit( $hex ) ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if ( strlen( $hex ) !== 6 || ! ctype_xdigit( $hex ) ) {
return $fallback;
}
return '#' . strtolower( $hex );
}
}
if ( ! function_exists( 'customify_color_hex_to_rgb' ) ) {
/**
* Parse a color string to [r, g, b] integer triplet (0-255 each).
* Accepts both hex (#rrggbb / #rgb) and rgb()/rgba() forms — alpha
* is ignored. The function name keeps the legacy `hex_to_rgb` for
* backcompat with downstream callers; new rgba support means the
* on-* / container / border-strong derivations don't silently drop
* to [0,0,0] when the user picks a transparent brand color.
*
* Returns [0, 0, 0] for invalid input — math helpers downstream
* handle that as black (luminance 0).
*/
function customify_color_hex_to_rgb( $value ) {
$value = (string) $value;
// rgb()/rgba() form — capture first 3 channel ints, ignore alpha.
// Anchor trailing `)` so cut-off inputs don't pass — keeps the
// regex consistent with normalize_hex and composite_over.
if ( preg_match( '/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*[\d.]+)?\s*\)$/i', $value, $m ) ) {
return array(
max( 0, min( 255, (int) $m[1] ) ),
max( 0, min( 255, (int) $m[2] ) ),
max( 0, min( 255, (int) $m[3] ) ),
);
}
// Hex form.
$hex = ltrim( $value, '#' );
if ( strlen( $hex ) === 3 && ctype_xdigit( $hex ) ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
if ( strlen( $hex ) !== 6 || ! ctype_xdigit( $hex ) ) {
return array( 0, 0, 0 );
}
return array(
hexdec( substr( $hex, 0, 2 ) ),
hexdec( substr( $hex, 2, 2 ) ),
hexdec( substr( $hex, 4, 2 ) ),
);
}
}
if ( ! function_exists( 'customify_color_rgb_to_hex' ) ) {
function customify_color_rgb_to_hex( $rgb ) {
return sprintf( '#%02x%02x%02x',
max( 0, min( 255, (int) round( $rgb[0] ) ) ),
max( 0, min( 255, (int) round( $rgb[1] ) ) ),
max( 0, min( 255, (int) round( $rgb[2] ) ) )
);
}
}
if ( ! function_exists( 'customify_color_mix_hex' ) ) {
/**
* Mix two hex colors in sRGB space.
*
* @param string $a First color hex.
* @param string $b Second color hex.
* @param float $weight_a Weight of color A in [0,1]. 1.0 = pure A, 0.0 = pure B.
* @return string Mixed hex.
*/
function customify_color_mix_hex( $a, $b, $weight_a ) {
$weight_a = max( 0.0, min( 1.0, (float) $weight_a ) );
$wb = 1.0 - $weight_a;
$ra = customify_color_hex_to_rgb( $a );
$rb = customify_color_hex_to_rgb( $b );
return customify_color_rgb_to_hex( array(
$ra[0] * $weight_a + $rb[0] * $wb,
$ra[1] * $weight_a + $rb[1] * $wb,
$ra[2] * $weight_a + $rb[2] * $wb,
) );
}
}
if ( ! function_exists( 'customify_color_relative_luminance' ) ) {
function customify_color_relative_luminance( $hex ) {
list( $r, $g, $b ) = customify_color_hex_to_rgb( $hex );
$f = function ( $v ) {
$v = $v / 255;
return $v <= 0.03928 ? $v / 12.92 : pow( ( $v + 0.055 ) / 1.055, 2.4 );
};
return 0.2126 * $f( $r ) + 0.7152 * $f( $g ) + 0.0722 * $f( $b );
}
}
if ( ! function_exists( 'customify_color_composite_over' ) ) {
/**
* Composite a (possibly-transparent) color over an opaque base. Returns
* the [r, g, b] of what would actually be RENDERED if `$value` were
* painted on top of `$base_hex`.
*
* Why: when a user picks rgba(brand, alpha=0.14) for the Primary slot
* in the WP color picker, the BUTTON background is rgba composited
* over the page bg (the user's saved Base, usually white). The on-*
* WCAG safety pick needs to contrast against THAT composite, not
* against the opaque rgb component of the rgba (which would still be
* the dark brand color and incorrectly pick white text).
*
* For opaque values (hex / rgb / rgba alpha=1) the function returns
* the rgb triplet unchanged — equivalent to the bare hex_to_rgb call.
*
* @param string $value Any color string accepted by hex_to_rgb plus rgba().
* @param string $base_hex The opaque background to composite over (usually slot.base).
* @return array [r, g, b] integer triplet 0-255.
*/
function customify_color_composite_over( $value, $base_hex ) {
$value = (string) $value;
// rgba() with explicit alpha — composite.
if ( preg_match( '/^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([\d.]+)\s*\)/i', $value, $m ) ) {
$r = max( 0, min( 255, (int) $m[1] ) );
$g = max( 0, min( 255, (int) $m[2] ) );
$b = max( 0, min( 255, (int) $m[3] ) );
$a = max( 0.0, min( 1.0, (float) $m[4] ) );
if ( $a >= 1.0 ) {
return array( $r, $g, $b );
}
// When base_hex is invalid (returns [0,0,0] from hex_to_rgb),
// substitute white instead — base is overwhelmingly the page
// background, so an invalid/empty base reading composites the
// rgba over the visual "page color" that most users have. Same
// fallback as the JS mirror (_compositeOver) for parity.
$base_rgb = customify_color_hex_to_rgb( $base_hex );
if ( 0 === $base_rgb[0] && 0 === $base_rgb[1] && 0 === $base_rgb[2] && '#000000' !== strtolower( (string) $base_hex ) ) {
$base_rgb = array( 255, 255, 255 );
}
return array(
(int) round( $r * $a + $base_rgb[0] * ( 1 - $a ) ),
(int) round( $g * $a + $base_rgb[1] * ( 1 - $a ) ),
(int) round( $b * $a + $base_rgb[2] * ( 1 - $a ) ),
);
}
// Opaque (hex or rgb without alpha) — passthrough.
return customify_color_hex_to_rgb( $value );
}
}
if ( ! function_exists( 'customify_color_wcag_contrast' ) ) {
/**
* WCAG 2.x contrast ratio between two hex colors. Returns a value in
* [1.0, 21.0] where higher = more contrast. Used as the foundation
* for max-contrast picks (§3) and L-reduction loops (§5) per the
* color-token-derivation spec.
*/
function customify_color_wcag_contrast( $a_hex, $b_hex ) {
$la = customify_color_relative_luminance( $a_hex );
$lb = customify_color_relative_luminance( $b_hex );
$hi = max( $la, $lb );
$lo = min( $la, $lb );
return ( $hi + 0.05 ) / ( $lo + 0.05 );
}
}
if ( ! function_exists( 'customify_color_pick_on' ) ) {
/**
* Pick max-contrast text color (#FFFFFF or #1A1A1A) for a given background.
*
* Spec §3: `on-X = contrast(LIGHT, X') >= contrast(DARK, X') ? LIGHT : DARK`
* where X' is the EFFECTIVE rendered color — for rgba inputs X' is the
* composite over the page base (since that's what the user sees behind
* the text), NOT the opaque rgb component of the rgba.
*
* Max-contrast pick — pick whichever of #FFFFFF / #1A1A1A has higher
* WCAG contrast against the effective bg. More robust than a fixed
* luminance threshold (e.g. `> 0.45`), which silently picks white on
* medium-tone colors where black would be more readable.
*
* Example 1 — teal #3CAA9D
* • Threshold (luminance > 0.45): luminance ≈ 0.34 → returns white →
* contrast 2.87 FAILS WCAG.
* • Max-contrast: contrast(white, teal)=2.87 vs contrast(black, teal)=6.16
* → returns black → PASSES.
*
* Example 2 — rgba(17,52,109,0.14) on white base
* • Opaque rgb component: (17,52,109) — dark navy → max-contrast picks
* WHITE (correct against opaque navy, WRONG against rendered output).
* • Effective composite over white: ~(220,225,233) — very light blue
* → max-contrast picks DARK ✓ matches what the user actually sees.
*
* @param string $bg_value Color string (hex, rgb, or rgba).
* @param string $base_hex Opaque base to composite over for rgba inputs.
* Defaults to #FFFFFF (white) — pass the saved
* Palette Base for accurate dark-mode pick.
* @return string '#FFFFFF' or '#1A1A1A'.
*/
function customify_color_pick_on( $bg_value, $base_hex = '#FFFFFF' ) {
$rgb = customify_color_composite_over( $bg_value, $base_hex );
$effective = customify_color_rgb_to_hex( $rgb );
$c_light = customify_color_wcag_contrast( '#FFFFFF', $effective );
$c_dark = customify_color_wcag_contrast( '#1A1A1A', $effective );
return $c_light >= $c_dark ? '#FFFFFF' : '#1A1A1A';
}
}
if ( ! function_exists( 'customify_color_srgb_to_oklab' ) ) {
/**
* Convert sRGB hex → OKLab (L, a, b). Standard Ottosson transform.
* L is in roughly [0, 1] (0 = black, 1 = white). a, b are unbounded
* chromaticity channels.
*
* Used by §4 (container P solve) — measures L of source + base to
* solve the percentage that lands the container at TARGET_CONTAINER_L.
* Used by §5 (on-container L-reduction) — keeps a, b (hue) constant
* while stepping L down until contrast meets AA.
*/
function customify_color_srgb_to_oklab( $hex ) {
list( $r, $g, $b ) = customify_color_hex_to_rgb( $hex );
// sRGB → linear.
$f = function ( $v ) {
$v = $v / 255;
return $v <= 0.04045 ? $v / 12.92 : pow( ( $v + 0.055 ) / 1.055, 2.4 );
};
$rl = $f( $r );
$gl = $f( $g );
$bl = $f( $b );
// Linear sRGB → LMS (Ottosson).
$l = 0.4122214708 * $rl + 0.5363325363 * $gl + 0.0514459929 * $bl;
$m = 0.2119034982 * $rl + 0.6806995451 * $gl + 0.1073969566 * $bl;
$s = 0.0883024619 * $rl + 0.2817188376 * $gl + 0.6299787005 * $bl;
// Cube-root.
$l_ = $l < 0 ? -pow( -$l, 1.0 / 3.0 ) : pow( $l, 1.0 / 3.0 );
$m_ = $m < 0 ? -pow( -$m, 1.0 / 3.0 ) : pow( $m, 1.0 / 3.0 );
$s_ = $s < 0 ? -pow( -$s, 1.0 / 3.0 ) : pow( $s, 1.0 / 3.0 );
// LMS' → OKLab.
return array(
0.2104542553 * $l_ + 0.7936177850 * $m_ - 0.0040720468 * $s_, // L
1.9779984951 * $l_ - 2.4285922050 * $m_ + 0.4505937099 * $s_, // a
0.0259040371 * $l_ + 0.7827717662 * $m_ - 0.8086757660 * $s_, // b
);
}
}
if ( ! function_exists( 'customify_color_oklab_to_srgb' ) ) {
/**
* Convert OKLab (L, a, b) → sRGB hex. Inverse of srgb_to_oklab.
* Clamps the output to valid sRGB [0, 255] range so out-of-gamut
* results don't crash — they get pinned to the nearest representable
* color, which is acceptable for our derivation use case.
*/
function customify_color_oklab_to_srgb( $oklab ) {
list( $L, $a, $b ) = $oklab;
// OKLab → LMS'.
$l_ = $L + 0.3963377774 * $a + 0.2158037573 * $b;
$m_ = $L - 0.1055613458 * $a - 0.0638541728 * $b;
$s_ = $L - 0.0894841775 * $a - 1.2914855480 * $b;
// Cube.
$l = $l_ * $l_ * $l_;
$m = $m_ * $m_ * $m_;
$s = $s_ * $s_ * $s_;
// LMS → linear sRGB.
$rl = 4.0767416621 * $l - 3.3077115913 * $m + 0.2309699292 * $s;
$gl = -1.2684380046 * $l + 2.6097574011 * $m - 0.3413193965 * $s;
$bl = -0.0041960863 * $l - 0.7034186147 * $m + 1.7076147010 * $s;
// Linear → sRGB.
$g = function ( $v ) {
$v = max( 0.0, min( 1.0, $v ) );
return $v <= 0.0031308 ? $v * 12.92 : 1.055 * pow( $v, 1.0 / 2.4 ) - 0.055;
};
return customify_color_rgb_to_hex( array(
$g( $rl ) * 255,
$g( $gl ) * 255,
$g( $bl ) * 255,
) );
}
}
if ( ! function_exists( 'customify_color_oklab_l' ) ) {
/**
* Extract just the OKLab L channel (lightness) from a hex color.
* Thin wrapper used by §4 P-solve readability.
*/
function customify_color_oklab_l( $hex ) {
$oklab = customify_color_srgb_to_oklab( $hex );
return $oklab[0];
}
}
if ( ! function_exists( 'customify_color_solve_border_strong' ) ) {
/**
* Spec §2 — solve P (text-vs-base mix percentage) such that the
* resulting color has WCAG contrast ≥ 3.0 against base.
*
* UI_CONTRAST = 3.0 per WCAG 1.4.11 for non-text content (form input
* borders, button outlines — anything where the boundary is the only
* cue that there's a control).
*
* Iterates P from 6% upward in 1% steps (closed-form solve is messy
* across the sRGB gamma curve; iterative is simpler and fast). For
* the default (text=#2b2b2b, base=#FFFFFF) palette lands ~47% → ~#949494.
*/
function customify_color_solve_border_strong( $text_hex, $base_hex ) {
for ( $p = 6; $p <= 100; $p++ ) {
$mix = customify_color_mix_hex( $text_hex, $base_hex, $p / 100 );
$contrast = customify_color_wcag_contrast( $mix, $base_hex );
if ( $contrast >= 3.0 ) {
return $mix;
}
}
// Fallback if base ≈ text (shouldn't happen with sane palettes).
return $text_hex;
}
}
if ( ! function_exists( 'customify_color_chroma_cap_oklab' ) ) {
/**
* Cap a color's OKLab chroma (= sqrt(a² + b²)) to a maximum value
* while preserving its L and hue direction.
*
* Used to tame high-chroma brand colors (notably yellow / lime /
* neon) when computing container tints. The spec §4 formula lands
* containers at OKLab L = 0.93 but doesn't adjust chroma — for a
* dark navy brand the OKLab-mix with white naturally desaturates
* to a soft blue-grey (chroma ~0.01), but for a yellow brand the
* mix preserves most of yellow's chroma (~0.10) because yellow is
* already perceptually light. Result: yellow's container reads
* "still very yellow" instead of "soft cream", breaking the badge
* aesthetic that the container pattern targets.
*
* Capping chroma to ~0.04 produces a cream/peach feel for high-
* chroma brands while leaving low-chroma brands unaffected (their
* container chroma is already well under the cap).
*
* @param string $hex
* @param float $max_chroma Maximum allowed sqrt(a² + b²) in OKLab.
* @return string Capped hex (same color if already under cap).
*/
function customify_color_chroma_cap_oklab( $hex, $max_chroma ) {
$oklab = customify_color_srgb_to_oklab( $hex );
$L = $oklab[0];
$a = $oklab[1];
$b = $oklab[2];
$chroma = sqrt( $a * $a + $b * $b );
if ( $chroma <= $max_chroma ) {
return customify_color_normalize_hex( $hex, $hex );
}
$scale = $max_chroma / $chroma;
return customify_color_oklab_to_srgb( array( $L, $a * $scale, $b * $scale ) );
}
}
if ( ! function_exists( 'customify_color_solve_container_p' ) ) {
/**
* Spec §4 — closed-form solve for the percentage P that lands a tint
* of `source` mixed with `base` at OKLab lightness TARGET_CONTAINER_L
* (= 0.93, the perceptual lightness for soft-tint badges).
*
* P = clamp( (TARGET_L - L_oklab(base)) / (L_oklab(source) - L_oklab(base)),
* 0.02, 0.98 )
*
* Returns a float in [0.02, 0.98] (the percentage as a 0..1 fraction).
* Multiply by 100 when emitting into a `color-mix(in oklab, A {P}%, B)`
* expression. Clamped so we never hit pathological 0%/100% mixes.
*/
function customify_color_solve_container_p( $source_hex, $base_hex, $target_l = 0.93 ) {
$l_source = customify_color_oklab_l( $source_hex );
$l_base = customify_color_oklab_l( $base_hex );
$denom = $l_source - $l_base;
if ( abs( $denom ) < 1e-6 ) {
// source ≈ base lightness → can't make a meaningful tint. Pin to 50%.
return 0.5;
}
$p = ( $target_l - $l_base ) / $denom;
return max( 0.02, min( 0.98, $p ) );
}
}
if ( ! function_exists( 'customify_color_l_reduce_until_contrast' ) ) {
/**
* Spec §5 — keep source hue (a, b) in OKLab, step L downward until the
* resulting color has contrast ≥ $target_contrast against $bg_hex.
*
* Used to produce `on-X-container` from a brand color X. For a dark
* brand (primary navy), the loop barely steps L because contrast is
* already adequate → result ≈ source. For a light brand (accent
* yellow), L drops significantly → dark gold result.
*
* If no L in [0, source_L] passes the target, returns #1A1A1A as the
* unconditional fallback.
*/
function customify_color_l_reduce_until_contrast( $source_hex, $bg_hex, $target_contrast = 4.5 ) {
$oklab = customify_color_srgb_to_oklab( $source_hex );
$L = $oklab[0];
$a = $oklab[1];
$b = $oklab[2];
// Step L downward in 0.02 increments (50 steps from L=1 to L=0 worst case).
while ( $L > 0 ) {
$candidate = customify_color_oklab_to_srgb( array( $L, $a, $b ) );
$contrast = customify_color_wcag_contrast( $candidate, $bg_hex );
if ( $contrast >= $target_contrast ) {
return $candidate;
}
$L -= 0.02;
}
return '#1A1A1A';
}
}
// ──────────────────────────────────────────────────────────────────
// Slot resolver — reads 6 slot keys with defaults.
// ──────────────────────────────────────────────────────────────────
if ( ! function_exists( 'customify_color_get_slots' ) ) {
function customify_color_get_slots() {
// Slot primary / secondary reuse existing legacy theme_mod keys to keep
// storage compatibility with 30K+ sites. Every read is normalized to
// guard against wp-cli / external writes that bypass sanitize_callback.
// Surface default `#f9f9f9` aligns with the canvas-tinted SCSS hardcode
// historically used on `.page-titlebar` (and similar elevated chrome).
// Wiring the titlebar SCSS to `var(--customify-surface, #f9f9f9)` means
// 30K legacy sites keep the byte-identical render (fallback fires when
// the surface var isn't emitted to :root), while palette-opt-in sites
// pick up a coherent surface tone — e.g. dark-base saved → surface
// auto-tones with it instead of leaving the titlebar stuck at light gray.
$defaults = array(
'base' => '#FFFFFF',
'surface' => '#f9f9f9',
'text' => '#2b2b2b',
'primary' => '#0e7c7b',
'secondary' => '#c3512f',
'accent' => '#FFD042',
);
$keys = array(
'base' => 'customify_palette_base',
'surface' => 'customify_palette_surface',
'text' => 'customify_palette_text',
'primary' => 'global_styling_color_primary',
'secondary' => 'global_styling_color_secondary',
'accent' => 'customify_palette_accent',
);
$slots = array();
foreach ( $defaults as $slot => $default ) {
$raw = get_theme_mod( $keys[ $slot ], $default );
$slots[ $slot ] = customify_color_normalize_hex( $raw, $default );
}
return $slots;
}
}
// ──────────────────────────────────────────────────────────────────
// :root CSS block builder.
// ──────────────────────────────────────────────────────────────────
if ( ! function_exists( 'customify_color_palette_root_css' ) ) {
function customify_color_palette_root_css() {
$slots = customify_color_get_slots();
// Static hex fallbacks for derived vars (PHP-precomputed).
// `border` mix at 9% — lighter than the spec-§2 14% the project briefly
// shipped (which renders #e1e1e1 on default install vs the legacy
// #eaecee, a perceptible darkening). 9% lands render at #ECECEC, the
// grayscale equivalent of legacy avg (ΔE ≈ 1.1 vs #eaecee). Decorative-
// only (WCAG-exempt) per spec — functional-control borders should use
// `border-strong` instead. The fallback expressions in inc/customizer/
// configs/colors.php are kept in sync at the same 9% percentage.
$text_muted_default = customify_color_mix_hex( $slots['text'], $slots['base'], 0.70 );
$border_default = customify_color_mix_hex( $slots['text'], $slots['base'], 0.09 );
// `border-strong` solved per spec §2 — smallest P where WCAG contrast
// vs base ≥ 3.0. Used by form input borders / button outlines / any
// boundary that's the ONLY cue identifying a functional control.
$border_strong_default = customify_color_solve_border_strong( $slots['text'], $slots['base'] );
$primary_hover_default = customify_color_mix_hex( $slots['primary'], '#000000', 0.90 ); // primary at 90%, black at 10%
// Link hover defaults to the SAME as link (which itself defaults to the
// Primary slot) — hovering keeps the link colour unless the user saves a
// Link-hover override. Project-owner decision (was: link lighter 15%).
$link_hover_default = $slots['primary'];
// Body text default = slot.text directly (same pattern as heading).
// Earlier Phase 2.3 used mix(text, base, 88%) for a softer ink,
// but that desaturates the user's Text slot — e.g. setting Text to
// pure white on a dark base yields ~#e0e0e0 grey body copy
// instead of the explicit white. Use slot.text verbatim so body
// copy fully respects whatever the user picked.
$body_text_default = $slots['text'];
// Override resolution — legacy explicit values win over computed defaults.
// Each override is normalized; an invalid stored value (e.g. from a
// rogue wp-cli write) is treated as "no override" and the derived
// fallback kicks in instead of polluting the :root with garbage.
//
// Critical: `get_theme_mod()` inside the Customizer preview iframe falls
// back to the customize control's registered default, NOT to our `null`
// argument — so passing `null` here would still resolve to the field
// default and look like an explicit user override, suppressing the
// cascade. Read straight from the raw saved-mods array so the override
// only counts when the user actually saved something. Outside the
// customizer, get_theme_mods() returns the same data as get_theme_mod().
$_saved_mods = get_theme_mods();
$_get_saved = function ( $key ) use ( $_saved_mods ) {
return ( is_array( $_saved_mods ) && array_key_exists( $key, $_saved_mods ) )
? $_saved_mods[ $key ]
: null;
};
$ov_text_muted = customify_color_normalize_hex( $_get_saved( 'global_styling_color_meta' ), '' );
$ov_border = customify_color_normalize_hex( $_get_saved( 'global_styling_color_border' ), '' );
$ov_link = customify_color_normalize_hex( $_get_saved( 'global_styling_color_link' ), '' );
$ov_link_hover = customify_color_normalize_hex( $_get_saved( 'global_styling_color_link_hover' ), '' );
$ov_heading = customify_color_normalize_hex( $_get_saved( 'global_styling_color_heading' ), '' );
$ov_widget_title = customify_color_normalize_hex( $_get_saved( 'global_styling_color_w_title' ), '' );
$ov_body_text = customify_color_normalize_hex( $_get_saved( 'global_styling_color_text' ), '' );
$text_muted = $ov_text_muted ?: $text_muted_default;
// Border emitted UNCONDITIONALLY (Phase 2.13 follow-up — was
// previously gated on saved override per Phase 2.6, which left
// dozens of `var(--customify-border, color-mix(currentcolor X%,
// transparent))` consumers in inc/customizer/configs/colors.php
// falling back to the currentcolor mix. On dark headers / dark
// page-titlebars / dark hero sections, that fallback resolves
// to ~14% of white on dark bg ≈ invisible — leading to the
// reported "page-titlebar lost its border" issue.
//
// Using the slot-derived default (mix(text, base, 9%) — see border
// §2) gives a concrete hex value that's visible regardless of
// the consuming element's text color. Saved override still wins.
// 30K safety: same logic as Phase 2.13 on-* gate drop — the
// CSS rules that consume this var have always been there with a
// fallback; emitting a concrete value just makes them render
// reliably instead of relying on context-dependent currentcolor.
$border = $ov_border ?: $border_default;
$link = $ov_link ?: $slots['primary'];
$link_hover = $ov_link_hover ?: $link_hover_default;
$heading = $ov_heading ?: $slots['text'];
$widget_title = $ov_widget_title ?: $slots['text'];
// Body text default uses its own mix (88% ink) — stronger than
// text-muted (70%, used for meta / secondary copy) so body copy
// stays legible while headings keep contrast above it.
$body_text = $ov_body_text ?: $body_text_default;
// Contrast picks for on-* (PHP-precomputed).
//
// 30K-site safety gate: only emit auto-contrast on-* tokens when the
// user has explicitly engaged with the new Palette panel (saved any
// of the 4 truly-new slot keys: base, surface, text, accent — none
// of which existed pre-Phase-2). For legacy sites that have only
// touched the long-standing primary/secondary keys (or nothing),
// leaving the on-* tokens UNSET means bundled rules of the form
// `color: var(--customify-on-primary, #fff)` fall back to the
// literal #fff hex — byte-equivalent to the pre-refactor hard-coded
// `color: #fff;` everywhere buttons consume these tokens.
//
// The 4 slot keys are checked via array_key_exists() on the raw
// saved-mods array (NOT get_theme_mod() — that returns field defaults
// inside the customize preview and would falsely look "saved"; see
// the same lesson in the override-resolution block above).
$has_palette_opt_in = (
is_array( $_saved_mods ) && (
array_key_exists( 'customify_palette_base', $_saved_mods ) ||
array_key_exists( 'customify_palette_surface', $_saved_mods ) ||
array_key_exists( 'customify_palette_text', $_saved_mods ) ||
array_key_exists( 'customify_palette_accent', $_saved_mods )
)
);
// §3 on-* tokens — UNCONDITIONALLY emitted, NOT gated on the
// 4-new-slot-keys palette opt-in. Rationale: the SCSS auto-wire
// (`.has-primary-background-color { color: var(--customify-on-primary,
// inherit) }`) needs on-* present at render time even when the
// user only changed legacy Primary/Secondary/Accent slots (not the
// new Phase 2 slots). With the old gate, changing Primary in the
// Customizer didn't make text auto-flip because on-primary stayed
// unset → fallback `inherit` → body text color (dark on dark = bad).
//
// 30K safety preserved: the on-* tokens are CONSUMED only by the
// new auto-wire SCSS rule on the new picker slugs. Legacy sites
// without `.has-primary-background-color` blocks see no behavioral
// change — the var() is set in :root but no rule references it.
// Sites that DO have `.has-primary-background-color` blocks gain
// the auto-readability safety net; this is a strict UX improvement
// over the pre-PR behavior (text was inheriting body color, often
// failing contrast against a brand bg).
//
// §3 — on-X = max-contrast against the EFFECTIVE rendered color.
// Pass slot.base so rgba brand values composite correctly before
// the contrast pick (the helper passes opaque colors through
// unchanged, so this is a no-op for hex inputs).
$on_primary = customify_color_pick_on( $slots['primary'], $slots['base'] );
$on_secondary = customify_color_pick_on( $slots['secondary'], $slots['base'] );
$on_accent = customify_color_pick_on( $slots['accent'], $slots['base'] );
// On-surface contrast — picks against the saved Surface, or the
// SCSS var() fallback (#FFFFFF in `.is-style-card`) if Surface
// isn't saved. Same unconditional emit rationale as the on-X
// solid picks above.
$surface_effective = ( is_array( $_saved_mods ) && array_key_exists( 'customify_palette_surface', $_saved_mods ) )
? $slots['surface']
: '#FFFFFF';
$on_surface = customify_color_pick_on( $surface_effective, $slots['base'] );
if ( $has_palette_opt_in ) {
// §4 — *-container = soft tint of brand at OKLab L ≈ 0.93.
// Each `customify_color_solve_container_p()` returns the
// percentage that lands the source color's mix-with-base at
// the target lightness. We emit the result as a `color-mix`
// expression with the percentage frozen at compute time, so
// the container var() chain looks like:
// --customify-primary-container: color-mix(in oklab,
// var(--customify-primary) {P}%, var(--customify-base));
// Browsers re-resolve the var() at render time → when the
// user drags Primary in the Customizer, the container updates
// without a full PHP re-emit (the live-preview JS still has
// to call the same solver to recompute {P} when the source
// brand changes — handled in palette_preview_js).
$primary_container_p = customify_color_solve_container_p( $slots['primary'], $slots['base'] );
$secondary_container_p = customify_color_solve_container_p( $slots['secondary'], $slots['base'] );
$accent_container_p = customify_color_solve_container_p( $slots['accent'], $slots['base'] );
// Precompute the resulting hex for each container, then apply the
// chroma cap so on-X-container is solved against the ACTUAL
// container color that gets rendered (not the un-capped raw mix).
// Otherwise the L-reduction loop would optimize for a saturated
// container that no longer exists once the cap is applied.
$container_max_chroma_compute = 0.04;
$primary_container_hex = customify_color_chroma_cap_oklab( customify_color_mix_hex( $slots['primary'], $slots['base'], $primary_container_p ), $container_max_chroma_compute );
$secondary_container_hex = customify_color_chroma_cap_oklab( customify_color_mix_hex( $slots['secondary'], $slots['base'], $secondary_container_p ), $container_max_chroma_compute );
$accent_container_hex = customify_color_chroma_cap_oklab( customify_color_mix_hex( $slots['accent'], $slots['base'], $accent_container_p ), $container_max_chroma_compute );
// §5 — on-X-container = darken brand hue (keep OKLab a, b) until
// the result has WCAG contrast ≥ 4.5 against the container.
// For dark brands (primary navy) the loop barely moves L → result
// ≈ source. For light brands (accent yellow) L drops significantly
// → dark gold result. Either way, AA against the container is
// guaranteed. NOT exposed in the Blocksify picker (theme internals
// only) per the user's design decision — block authors typically
// pick the source brand color directly as text on its container.
$on_primary_container = customify_color_l_reduce_until_contrast( $slots['primary'], $primary_container_hex );
$on_secondary_container = customify_color_l_reduce_until_contrast( $slots['secondary'], $secondary_container_hex );
$on_accent_container = customify_color_l_reduce_until_contrast( $slots['accent'], $accent_container_hex );
} else {
$primary_container_p = $secondary_container_p = $accent_container_p = null;
$on_primary_container = $on_secondary_container = $on_accent_container = null;
}
// Surface slot is the elevated-container background (cards / table
// cells / code blocks / form inputs / calendar headers / page
// titlebar). Only emit to :root when the user EXPLICITLY saved
// palette_surface — for unsaved sites the bundled SCSS fallback
// resolves to `color-mix(in srgb, currentcolor 6-12%, transparent)`
// for the card/widget/code surface tints, while `.page-titlebar`
// uses the slot default `#f9f9f9` as its fallback (matches the
// historical hardcoded SCSS). Emitting the slot default here would
// bake a fixed light gray into :root and break the adaptive
// fallback for users who saved Base = dark but didn't touch Surface.
// (Note: --customify-border used to follow this same opt-in gate
// per Phase 2.6, but was switched to unconditional emit in Phase
// 2.13 to fix invisible-border-on-dark-surface regressions —
// surface stays gated because cards/widgets/code blocks consume the
// adaptive 6-12% fallback that the rigid hex would suppress.)
$ov_surface = ( is_array( $_saved_mods ) && array_key_exists( 'customify_palette_surface', $_saved_mods ) )
? $slots['surface']
: null;
$lines = array(
"--customify-base: {$slots['base']}",
"--customify-text: {$slots['text']}",
"--customify-primary: {$slots['primary']}",
"--customify-secondary: {$slots['secondary']}",
"--customify-accent: {$slots['accent']}",
"--customify-text-muted: {$text_muted}",
"--customify-body-text: {$body_text}",
"--customify-primary-hover: {$primary_hover_default}",
"--customify-link: {$link}",
"--customify-link-hover: {$link_hover}",
"--customify-heading: {$heading}",
"--customify-widget-title: {$widget_title}",
);
// On-* contrast tokens — emitted UNCONDITIONALLY (see rationale
// above the $on_primary computation). Consumed by the SCSS
// auto-wire `.has-{brand}-background-color { color: var(--customify-on-{brand}, inherit) }`
// so brand-bg blocks get WCAG-readable text out of the box, with
// real-time recompute as user drags slot pickers in the Customizer.
$lines[] = "--customify-on-primary: {$on_primary}";
$lines[] = "--customify-on-secondary: {$on_secondary}";
$lines[] = "--customify-on-accent: {$on_accent}";
$lines[] = "--customify-on-surface: {$on_surface}";
// Button label tokens — GATED on Palette opt-in. Theme buttons read
// `var(--customify-btn-on-{primary,secondary}, #fff)`, so the 30K
// legacy install base that never engaged the Palette panel falls back
// to the historical hard-coded white — a saved light brand color no
// longer silently flips button labels to black on update. Opt-in sites
// ALIAS the unconditional on-* tokens, so labels keep the WCAG-contrast
// auto-flip and the alias re-resolves live when the preview JS drives
// --customify-on-* during slot drags (no extra preview JS needed). The
// block brand-bg auto-wire (.has-X-background-color) and the Blocksify
// fill-button rule keep reading the raw unconditional on-* tokens — they
// are intentionally NOT gated (see colors-palette $on_primary rationale).
if ( $has_palette_opt_in ) {
$lines[] = '--customify-btn-on-primary: var(--customify-on-primary)';
$lines[] = '--customify-btn-on-secondary: var(--customify-on-secondary)';
}
// --customify-border emitted UNCONDITIONALLY since the Phase 2.13
// follow-up — see the $border resolution block above for the full
// rationale. The slot-derived default (`mix(text, base, 9%)`) gives
// a concrete hex that's visible on any surface, instead of the
// pre-fix currentcolor-12% fallback that turned invisible on dark
// containers. Saved override is honored 1:1 by $border = $ov_border.
$lines[] = "--customify-border: {$border}";
// --customify-surface only emitted when user explicitly saved
// palette_surface (see $ov_surface above). Absence lets the
// bundled SCSS `$surface_subtle/medium/strong` fallback expressions
// (color-mix in srgb, currentcolor X%, transparent) fire so surface
// tints (table cells, code blocks, calendar headers, form inputs)
// adapt to the page background automatically.
if ( null !== $ov_surface ) {
$lines[] = "--customify-surface: {$ov_surface}";
}
// --customify-border-strong — emitted on palette opt-in, gated the
// same as other derived tokens. Theme.json palette fallback inside
// `var(--customify-border-strong, {hex})` covers the no-opt-in case
// so block authors still get a usable color in the picker.
// Future SCSS work will wire this to form input borders (per spec
// §2: form inputs need WCAG 1.4.11's ≥3:1 contrast, not the
// decorative ~1.35:1 of `--customify-border`).
if ( $has_palette_opt_in ) {
$lines[] = "--customify-border-strong: {$border_strong_default}";
}
// --customify-*-container — soft tints of brand colors at OKLab
// L ≈ 0.93 with chroma capped at 0.04 to keep high-chroma brands
// (yellow, lime, hot pink) from producing oversaturated tints.
// Gated on palette opt-in for the same 30K-safety reasons as
// other derived tokens — fresh sites get the theme.json palette
// fallback (precomputed capped hex) inside `var()`.
//
// Emit as STATIC hex (not `color-mix(...)` expression) because
// the chroma-cap step can't be expressed in CSS — color-mix gives
// us perceptual L blending but doesn't let us project the result
// back onto a max-chroma boundary. Static hex is fine: containers
// recompute on Customizer save (PHP re-renders :root) and on every
// live-preview slot drag (JS computes the same capped hex inline).
// The 30K-safe fallback in theme.json palette also uses the capped
// hex so picker swatches show the soft tint, not raw mix.
$container_max_chroma = 0.04;
if ( null !== $primary_container_p ) {
$raw = customify_color_mix_hex( $slots['primary'], $slots['base'], $primary_container_p );
$capped = customify_color_chroma_cap_oklab( $raw, $container_max_chroma );
$lines[] = "--customify-primary-container: {$capped}";
}
if ( null !== $secondary_container_p ) {
$raw = customify_color_mix_hex( $slots['secondary'], $slots['base'], $secondary_container_p );
$capped = customify_color_chroma_cap_oklab( $raw, $container_max_chroma );
$lines[] = "--customify-secondary-container: {$capped}";
}
if ( null !== $accent_container_p ) {
$raw = customify_color_mix_hex( $slots['accent'], $slots['base'], $accent_container_p );
$capped = customify_color_chroma_cap_oklab( $raw, $container_max_chroma );
$lines[] = "--customify-accent-container: {$capped}";
}
// --customify-on-*-container — darkened brand-hue text for AA
// contrast on the corresponding container. Emitted to :root but
// deliberately omitted from the Blocksify picker (theme internals).
// Block authors typically use the source brand color (`primary`,
// `secondary`, `accent`) directly as the text-on-container choice;
// these on-* tokens are the safety net for edge cases (e.g. user
// saves a pastel-light brand where the source-as-text pattern
// would fail AA).
if ( null !== $on_primary_container ) {
$lines[] = "--customify-on-primary-container: {$on_primary_container}";
}
if ( null !== $on_secondary_container ) {
$lines[] = "--customify-on-secondary-container: {$on_secondary_container}";
}
if ( null !== $on_accent_container ) {
$lines[] = "--customify-on-accent-container: {$on_accent_container}";
}
// Derived-token cascade lines — added AFTER the static lines so
// modern browsers re-resolve them when the underlying slot changes
// (e.g. drag the Text slot → headings update without save). Legacy
// browsers without var() support ignore the duplicate decl as invalid
// and the static line above still wins, keeping the precomputed hex.
//
// Only emit when there is no explicit override saved — an override
// must remain a frozen static value (cf. 30K-site safety doctrine).
if ( ! $ov_heading ) {
$lines[] = "--customify-heading: var(--customify-text, {$slots['text']})";
}
// Widget title cascade — same pattern as heading. Sites without a
// saved w_title override see widget titles follow slot.text. Saved
// override locks the static value.
if ( ! $ov_widget_title ) {
$lines[] = "--customify-widget-title: var(--customify-text, {$slots['text']})";
}
// Body text cascade — body follows slot.text directly (no mix).
// Same pattern as heading/widget-title: pure var() chain so user's
// Text slot value flows through unchanged. Saved body override
// suppresses this line and locks --customify-body-text to override.
if ( ! $ov_body_text ) {
$lines[] = "--customify-body-text: var(--customify-text, {$slots['text']})";
}
// Link cascade — link defaults to slot.primary directly (same
// pattern as heading→text). The CSS rule `a { color: var(--customify-link, ...) }`
// then resolves to whatever primary is set to, unless a saved
// link override beats it. Modern browsers see the cascade live;
// legacy browsers keep the static `--customify-link: <slot.primary>`
// emitted earlier in this block.
if ( ! $ov_link ) {
$lines[] = "--customify-link: var(--customify-primary, {$slots['primary']})";
}
// Link-hover cascade — follows Link (which follows Primary) so hovering
// keeps the link colour unless a Link-hover override is saved. Pure var()
// chain (no color-mix) now that hover == link.
if ( ! $ov_link_hover ) {
$lines[] = "--customify-link-hover: var(--customify-link, {$link_hover_default})";
}
$static_root = ':root{' . implode( ';', $lines ) . ';}';
// Modern-browser refinement via color-mix(in oklab, ...). Only emit for
// derived vars that DON'T have an explicit override saved — preserves
// legacy 30K-site behavior bit-for-bit.
$mix_lines = array();
if ( ! $ov_text_muted ) {
$mix_lines[] = '--customify-text-muted: color-mix(in oklab, var(--customify-text) 70%, var(--customify-base))';
}
// Body text cascade removed from @supports block — body now uses a
// pure var() chain (see static :root section above) instead of a
// color-mix so the Text slot value flows through unchanged.
//
// Border live-resolve: when no override saved, the static line
// emits a baked hex mix(text, base, 9%). That works at page-load
// but does NOT update when the user drags Text or Base in the
// Customizer live preview (the static value was computed once at
// PHP render time). The @supports block here re-emits the same
// value as a color-mix expression with var() refs — modern
// browsers re-resolve when the underlying slot vars change, so
// the live preview updates without a re-render.
if ( ! $ov_border ) {
$mix_lines[] = '--customify-border: color-mix(in oklab, var(--customify-text) 9%, var(--customify-base))';
}
$mix_lines[] = '--customify-primary-hover: color-mix(in oklab, var(--customify-primary), black 10%)';
// Link-hover is now a pure var() chain (= Link) emitted in the static
// $lines block above — no @supports color-mix line needed.
$css = $static_root;
if ( $mix_lines ) {
$css .= '@supports (color: color-mix(in oklab, red, blue)){:root{' . implode( ';', $mix_lines ) . ';}}';
}
// Background composite cascade — Page bg / Content Area bg / Site
// Content bg all fall back to `--customify-base` when the user has
// NOT explicitly saved a bg_color subfield in the corresponding
// composite styling control. When saved, the composite's own
// auto-CSS rule emits the literal hex and that wins via cascade
// order (palette-tokens loads AFTER customify-style-inline-css,
// but we suppress emission here for saved composites so the
// literal stays the only emitter for that selector).
$bg_composites = array(
array( 'key' => 'background', 'selector' => 'body' ),
array( 'key' => 'site_content_styling', 'selector' => '.site-content .content-area' ),
array( 'key' => 'content_background', 'selector' => '.site-content' ),
);
$bg_cascade_lines = array();
foreach ( $bg_composites as $comp ) {
$saved = isset( $_saved_mods[ $comp['key'] ]['normal']['bg_color'] )
? trim( (string) $_saved_mods[ $comp['key'] ]['normal']['bg_color'] )
: '';
if ( '' === $saved ) {
$bg_cascade_lines[] = $comp['selector'] . '{background-color:var(--customify-base, ' . $slots['base'] . ')}';
}
}
if ( $bg_cascade_lines ) {
$css .= implode( '', $bg_cascade_lines );
}
return $css;
}
}
// ──────────────────────────────────────────────────────────────────
// theme.json palette sync — block editor color picker reads from here.
// Slug contract is API surface for Blocksify starter templates: never rename.
// ──────────────────────────────────────────────────────────────────
// Note: the 3 NEW slot slugs (base, surface, accent) are declared statically
// in theme.json alongside the long-standing 8 entries (primary, secondary,
// text, link, heading, background, light-gray, dark-gray). They use the same
// default values as the slot defaults here; the live Customizer values drive
// the `:root` block above (consumed by frontend / Blocksify), while
// theme.json values feed the block editor color picker.
//
// Both pickers are in sync at theme defaults; if the user changes a slot in
// the Customizer, the :root var updates but theme.json palette default does
// not — this matches existing Customify behavior (Customizer color changes
// never propagated to the block editor palette before).
// ──────────────────────────────────────────────────────────────────
// Customizer-controls JS: inject a "From palette" quick-pick row at the
// bottom of every wp-color-picker popup inside the Colors section.
// Lets the user override any component color from the 6 brand slots in
// one click, instead of typing a hex.
// ──────────────────────────────────────────────────────────────────
if ( ! function_exists( 'customify_color_palette_quickpick_js' ) ) {
function customify_color_palette_quickpick_js() {
$slots = customify_color_get_slots();
// Brand-first display order to match the Palette section UI.
// `control` is the LI id (without the `customize-control-` prefix)
// used to detect whether an open picker is a palette slot — in that
// case the popup shows a hex input instead of the From-Palette row.
$payload = wp_json_encode( array(
'slots' => array(
array( 'key' => 'primary', 'label' => 'Primary', 'color' => $slots['primary'], 'control' => 'global_styling_color_primary' ),
array( 'key' => 'secondary', 'label' => 'Secondary', 'color' => $slots['secondary'], 'control' => 'global_styling_color_secondary' ),
array( 'key' => 'accent', 'label' => 'Accent', 'color' => $slots['accent'], 'control' => 'customify_palette_accent' ),
array( 'key' => 'text', 'label' => 'Text', 'color' => $slots['text'], 'control' => 'customify_palette_text' ),
array( 'key' => 'surface', 'label' => 'Surface', 'color' => $slots['surface'], 'control' => 'customify_palette_surface' ),
array( 'key' => 'base', 'label' => 'Base', 'color' => $slots['base'], 'control' => 'customify_palette_base' ),
),
) );
$script = "(function(\$){
var CFY_COLORS = {$payload};
var PALETTE_CONTROLS = CFY_COLORS.slots.map(function(s){ return s.control; });
// Map of override settings → cascade source slot (read from wp.customize
// on demand). Used to visually sync each override picker's swatch with
// the resolved cascade value when no user override has been saved.
// Source value drives the swatch DOM only — the override setting stays
// empty so the cascade keeps applying. The user dragging the override
// picker writes a real value and breaks out of cascade mode.
var CASCADE_MAP = {
'global_styling_color_link': 'global_styling_color_primary',
'global_styling_color_link_hover': 'global_styling_color_link',
'global_styling_color_heading': 'customify_palette_text',
'global_styling_color_w_title': 'customify_palette_text',
'global_styling_color_text': 'customify_palette_text'
};
// Field defaults — when an override's value equals its field default,
// we treat it as 'no override' and apply the cascade-sync display.
// Mirrors the 'default' keys in inc/customizer/configs/colors.php.
var FIELD_DEFAULTS = {
'global_styling_color_link': '#0e7c7b',
'global_styling_color_link_hover': '#0e7c7b',
'global_styling_color_heading': '#2b2b2b',
'global_styling_color_w_title': '#2b2b2b',
'global_styling_color_text': '#2b2b2b'
};
// Decode wp.customize value if Customify wrapped it as URL-encoded JSON.
function decodeValue(v) {
if (typeof v !== 'string') return v;
try { return JSON.parse(decodeURI(v)); } catch (e) { return v; }
}
// Resolve a setting's EFFECTIVE cascade value by walking the chain when the
// setting is itself unset (= empty or its field default). e.g. link-hover
// cascades from link, which cascades from primary — so the link-hover swatch
// tracks Primary even though its direct source is Link.
function resolveCascadeValue(id) {
var val = decodeValue(wp.customize(id).get() || '');
var src = CASCADE_MAP[id];
if (src && (val === '' || val === FIELD_DEFAULTS[id])) {
return resolveCascadeValue(src);
}
return val;
}
// Sync the override picker's swatch to its cascade source when the
// override is unset (= still equals the registered field default).
function syncCascadeSwatch(targetId) {
var sourceId = CASCADE_MAP[targetId];
if (!sourceId) return;
var li = document.getElementById('customize-control-' + targetId);
if (!li) return;
var saved = decodeValue(wp.customize(targetId).get() || '');
var unset = (saved === '' || saved === FIELD_DEFAULTS[targetId]);
if (!unset) {
// User override saved — let wp-color-picker paint as usual.
li.classList.remove('is-cascading');
li.style.removeProperty('--customify-cascade-display');
return;
}
var cascadeValue = resolveCascadeValue(sourceId);
if (!cascadeValue) return;
li.classList.add('is-cascading');
li.style.setProperty('--customify-cascade-display', cascadeValue);
}
function syncAllCascadeSwatches() {
Object.keys(CASCADE_MAP).forEach(syncCascadeSwatch);
}
// Bind to each cascade SOURCE so swatches refresh when the user drags
// Primary or Text. Also bind each target's own setting so saving an
// override flips it out of cascade mode immediately.
function wireCascadeListeners() {
if (typeof wp === 'undefined' || !wp.customize) return;
// Bind targets — react to override save / clear.
Object.keys(CASCADE_MAP).forEach(function(targetId){
wp.customize(targetId, function(value){
value.bind(function(){ setTimeout(function(){ syncCascadeSwatch(targetId); }, 16); });
});
});
// Bind sources — react to Primary / Text slot drags.
var sources = {};
Object.keys(CASCADE_MAP).forEach(function(t){ sources[CASCADE_MAP[t]] = true; });
Object.keys(sources).forEach(function(sourceId){
wp.customize(sourceId, function(value){
value.bind(function(){
setTimeout(syncAllCascadeSwatches, 16);
});
});
});
// Initial paint once controls mount.
setTimeout(syncAllCascadeSwatches, 200);
}
\$(wireCascadeListeners);
function getControlId(container) {
var li = container.closest ? container.closest('.customize-control') : null;
if ( ! li || ! li.id ) return '';
return li.id.replace(/^customize-control-/, '');
}
function removeAddons(container) {
var qp = container.querySelector ? container.querySelector('.customify-color-quickpick') : null;
if (qp) qp.parentNode.removeChild(qp);
var hex = container.querySelector ? container.querySelector('.customify-color-hexrow') : null;
if (hex) hex.parentNode.removeChild(hex);
}
// Build the From-Palette row used by component overrides (Link, Border,
// Heading, etc.) so the user can override a single element from the
// brand palette in one click.
function buildQuickPick(\$panel, currentVal) {
var \$row = \$('<div class=\"customify-color-quickpick\"></div>');
\$row.append('<span class=\"customify-color-quickpick__label\">From palette</span>');
CFY_COLORS.slots.forEach(function(s){
var color = (s.color || '').toLowerCase();
var \$sw = \$('<button type=\"button\" class=\"customify-color-quickpick__swatch\"></button>')
.css('background-color', color)
.attr('title', s.label)
.attr('data-label', s.label)
.attr('data-color', color);
if (color === currentVal) \$sw.addClass('is-active');
\$sw.on('click', function(e){
e.preventDefault();
e.stopPropagation();
\$panel.wpColorPicker('color', color);
\$row.find('.customify-color-quickpick__swatch').removeClass('is-active');
\$sw.addClass('is-active');
});
\$row.append(\$sw);
});
return \$row;
}
// Build the hex input + read-only token var rows for a palette slot.
// Two-way sync the hex with wp-color-picker via Iris events; the token
// var is purely informational (one-tap select-all for copy/paste).
function buildHexInput(\$panel, currentVal, slotKey) {
var \$row = \$('<div class=\"customify-color-hexrow\"></div>');
var \$input = \$('<input type=\"text\" class=\"customify-color-hex\" spellcheck=\"false\" autocomplete=\"off\" />').val(currentVal);
\$input.on('input', function(){
var v = (\$input.val() || '').trim();
if ( v && v.charAt(0) !== '#' ) v = '#' + v;
if ( /^#[0-9a-fA-F]{6}\$/.test(v) || /^#[0-9a-fA-F]{8}\$/.test(v) ) {
\$panel.wpColorPicker('color', v);
}
});
\$input.on('click', function(e){ e.stopPropagation(); });
\$panel.on('iris-customify-hex-sync', function(){
var v = \$panel.val();
if ( v && document.activeElement !== \$input[0] ) \$input.val(v);
});
\$row.append(\$input);
if ( slotKey ) {
var \$tokenRow = \$('<div class=\"customify-color-tokenrow\"></div>');
var \$token = \$('<input type=\"text\" class=\"customify-color-token\" readonly />')
.val('var(--customify-' + slotKey + ')');
\$token.on('focus', function(){ this.select(); });
\$token.on('click', function(e){ e.stopPropagation(); this.select(); });
\$tokenRow.append(\$token);
// Wrap both rows in a fragment-like jQuery set so the caller can
// append in one go without changing the public API.
return \$row.add(\$tokenRow);
}
return \$row;
}
function injectPopupAddon(container) {
var \$container = \$(container);
var \$panel = \$container.find('.customify--color-panel');
if ( ! \$panel.length ) return;
var currentVal = (\$panel.val() || '').toLowerCase();
var controlId = getControlId(container);
var slot = CFY_COLORS.slots.filter(function(s){ return s.control === controlId; })[0];
var isPalette = !!slot;
// If an addon is already present (pre-built on init), refresh its
// current-value state instead of rebuilding the DOM from scratch.
// Overrides now carry BOTH a hex row and a quick-pick row, so refresh
// either / both if present.
var existing = container.querySelector ? container.querySelector('.customify-color-quickpick, .customify-color-hexrow') : null;
if (existing) {
var hexInput = container.querySelector('.customify-color-hex');
if (hexInput && document.activeElement !== hexInput) {
hexInput.value = \$panel.val() || '';
}
var quickpick = container.querySelector('.customify-color-quickpick');
if (quickpick) {
quickpick.querySelectorAll('.customify-color-quickpick__swatch').forEach(function(sw){
sw.classList.toggle('is-active', (sw.getAttribute('data-color') || '').toLowerCase() === currentVal);
});
}
return;
}
var \$addon;
if (isPalette) {
// Palette slots: hex input + readonly token var (var(--customify-<slug>)).
\$addon = buildHexInput(\$panel, currentVal, slot.key);
} else {
// Component overrides (Link, Heading, Border, etc.): hex input
// (no token row — overrides don't have a stable slot slug to
// reference) PLUS the From-Palette quick-pick swatches so the
// user can either type a custom hex or one-tap a slot color.
\$addon = buildHexInput(\$panel, currentVal, null).add(buildQuickPick(\$panel, currentVal));
}
\$container.find('.wp-picker-holder').append(\$addon);
}
// Pre-build addons for every color picker in the Colors section right
// after Customizer init — that way opening a picker just refreshes the
// already-mounted addon (single class toggle / value update) instead of
// building the DOM + 6-7 buttons + handlers from scratch each time.
function prebuildAll(section) {
section.querySelectorAll('.wp-picker-container').forEach(function(c){
if ( c.querySelector('.customify-color-quickpick, .customify-color-hexrow') ) return;
injectPopupAddon(c);
});
section.querySelectorAll('.customify-input-color').forEach(refreshDirtyState);
}
// Toggle is-dirty on the picker row when the saved value diverges
// from the field's REGISTERED DEFAULT (data-default attribute, set
// from field.default in the color control template). SCSS uses
// is-dirty to reveal the small reset glyph next to the swatch.
//
// Pre-fix bug: this comparison used the value LOADED at page-render
// time (initialValues snapshot). When a user had already saved a
// non-default value (e.g. Base = #000000) before opening Customizer,
// the snapshot captured that as initial. Then current === initial
// meant the picker was never marked dirty, so the reset glyph
// never appeared and the user could not revert to the default.
//
// Fix: compare against data-default. Any saved override that differs
// from the registered default toggles is-dirty regardless of when
// the value was saved.
function refreshDirtyState(div) {
if (!div) return;
var input = div.querySelector('input.wp-color-picker');
if (!input) return;
var cur = (input.value || '').trim().toLowerCase();
var def = (div.getAttribute('data-default') || '').trim().toLowerCase();
var dirty = cur !== '' && cur !== def;
div.classList.toggle('is-dirty', dirty);
}
// Wire change listeners once. Two sources cover everything:
// • DOM events on the underlying wp-color-picker input — fires for
// Iris drag, hex paste, quickpick swatch click.
// • wp.customize setting bind — fires for programmatic .set() and
// for clicks on wp-picker-default (which resets value via the
// customize API, not via a DOM event on the input).
// Either way, refreshDirtyState re-evaluates and toggles is-dirty.
\$(document).on('input change keyup', '#sub-accordion-section-customify_colors input.wp-color-picker, #sub-accordion-section-customify_colors .customify-color-hex', function(){
var li = \$(this).closest('.customify-input-color')[0];
refreshDirtyState(li);
});
// Bind to each picker's underlying wp.customize Setting once it's
// available so reset-button clicks (which go through the customize
// API) also refresh the dirty state. `.customify-input-color` is a
// DIV inside the control LI — settingId comes from the LI's id.
function bindSettingListeners() {
var section = document.getElementById('sub-accordion-section-customify_colors');
if (!section) return;
section.querySelectorAll('.customify-input-color').forEach(function(div){
if (div.dataset.dirtyBound) return;
var li = div.closest('.customize-control');
if (!li) return;
var settingId = (li.id || '').replace('customize-control-', '');
if (!settingId || !wp.customize(settingId)) return;
div.dataset.dirtyBound = '1';
wp.customize(settingId).bind(function(){
// Defer one tick so wp-color-picker has time to write the
// new value into the input before we read it.
setTimeout(function(){ refreshDirtyState(div); }, 16);
});
});
}
\$(bindSettingListeners);
setTimeout(bindSettingListeners, 600);
// Legacy fine-tuning section is collapsed by default. The .customize-control-title
// span inside the heading control gets a click handler; the LI itself stays
// inert so it doesn't pick up the browser's default focus outline (which
// otherwise paints a stuck rectangle around the entire heading row).
// State persists per-session via sessionStorage.
function setupLegacyCollapse() {
var section = document.getElementById('sub-accordion-section-customify_colors');
if (!section) return;
var heading = document.getElementById('customize-control-customify_colors_h_overrides');
if (!heading) return;
if (heading.classList.contains('customify-collapsible-heading')) return;
heading.classList.add('customify-collapsible-heading');
// Title span is the clickable target (chevron sits next to text).
var titleEl = heading.querySelector('.customize-control-title');
if (!titleEl) return;
titleEl.classList.add('customify-collapsible-toggle');
var targets = [];
var next = heading.nextElementSibling;
while (next) {
if (next.classList && next.classList.contains('customize-control')) targets.push(next);
next = next.nextElementSibling;
}
if (!targets.length) return;
var STORAGE_KEY = 'customify-legacy-collapsed';
var collapsed = sessionStorage.getItem(STORAGE_KEY) !== 'open';
function apply(state) {
targets.forEach(function(el){ el.style.display = state ? 'none' : ''; });
heading.classList.toggle('is-collapsed', state);
heading.classList.toggle('is-open', !state);
}
apply(collapsed);
titleEl.setAttribute('role', 'button');
titleEl.setAttribute('tabindex', '0');
titleEl.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
function toggle(e) {
e.preventDefault();
e.stopPropagation();
collapsed = !collapsed;
sessionStorage.setItem(STORAGE_KEY, collapsed ? 'closed' : 'open');
apply(collapsed);
titleEl.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
}
titleEl.addEventListener('click', toggle);
titleEl.addEventListener('keydown', function(e){
if (e.key === 'Enter' || e.key === ' ') toggle(e);
});
}
\$(setupLegacyCollapse);
setTimeout(setupLegacyCollapse, 400);
// Re-emit a custom event from any change on the picker so the hex input
// stays in sync while the user drags Iris's saturation / hue / alpha.
\$(document).on('input change', '#sub-accordion-section-customify_colors .customify--color-panel', function(){
\$(this).trigger('iris-customify-hex-sync');
});
// Monkey-patch jQuery's slide methods so Iris's wpColorPicker can't
// queue its slow slideToggle('fast') on our color picker holders. The
// override is scoped to elements WITH .wp-picker-holder — never matches
// the styling composite control's modal panel (which also lives in the
// Colors section and uses slideUp/slideDown for its own toggle, and
// relies on the animation-end callback to remove its .modal--opening
// class — making it instant would strand the modal open).
(function patchJQuerySlide(){
var origDown = \$.fn.slideDown;
var origUp = \$.fn.slideUp;
var origToggle = \$.fn.slideToggle;
function isPickerHolder(el){
return el && el.classList && el.classList.contains('wp-picker-holder')
&& el.closest && el.closest('#sub-accordion-section-customify_colors');
}
\$.fn.slideDown = function(){
if (this.length && isPickerHolder(this[0])) return this.show();
return origDown.apply(this, arguments);
};
\$.fn.slideUp = function(){
if (this.length && isPickerHolder(this[0])) return this.hide();
return origUp.apply(this, arguments);
};
\$.fn.slideToggle = function(){
if (this.length && isPickerHolder(this[0])) {
return this.is(':visible') ? this.hide() : this.show();
}
return origToggle.apply(this, arguments);
};
})();
// Iris stops propagation on its click handler, so jQuery delegation on
// document never sees the click. Instead we observe the .wp-picker-active
// class on each wp-picker-container inside the Colors section and add/
// remove our addon row in lock-step with the picker open/close.
function startObserver() {
var section = document.getElementById('sub-accordion-section-customify_colors');
if ( ! section ) {
setTimeout( startObserver, 500 );
return;
}
// Pre-build addons so opens feel instant — give Customify's initColor
// a tick to call wpColorPicker() and produce .wp-picker-container.
setTimeout(function(){ prebuildAll(section); }, 100);
setTimeout(function(){ prebuildAll(section); }, 800);
var observer = new MutationObserver(function(mutations){
mutations.forEach(function(m){
if ( m.type !== 'attributes' || m.attributeName !== 'class' ) return;
var target = m.target;
if ( ! target.classList || ! target.classList.contains('wp-picker-container') ) return;
if ( target.classList.contains('wp-picker-active') ) {
// Picker just opened — refresh existing addon (or create
// the first time if pre-build hadn't run for this picker yet).
injectPopupAddon(target);
}
// Note: no removeAddons on close — we keep the addon mounted
// so the next open is instant. wp-picker-holder is display:none
// via CSS when not .wp-picker-active so the addon is hidden too.
});
});
observer.observe(section, { attributes: true, subtree: true, attributeFilter: ['class'] });
}
\$(startObserver);
})(jQuery);";
wp_add_inline_script( 'customize-controls', $script );
}
add_action( 'customize_controls_enqueue_scripts', 'customify_color_palette_quickpick_js' );
}
// ──────────────────────────────────────────────────────────────────
// Force transport=postMessage on the 4 new slot settings.
// The 2 legacy slot keys (primary, secondary) already get postMessage
// via Customify_Customizer's css_format detection (class-customizer.php
// L1085-1097). The 4 new slot fields have empty css_format (they only
// feed :root vars, not auto-CSS rules) so they default to 'refresh' —
// override here so the preview JS below can live-update :root.
// Priority 1000 ensures Customify_Customizer::register (priority 666)
// has already added all settings.
// ──────────────────────────────────────────────────────────────────
if ( ! function_exists( 'customify_color_palette_force_postmessage' ) ) {
function customify_color_palette_force_postmessage( $wp_customize ) {
$slot_settings = array(
'customify_palette_base',
'customify_palette_surface',
'customify_palette_text',
'customify_palette_accent',
);
foreach ( $slot_settings as $setting_id ) {
$setting = $wp_customize->get_setting( $setting_id );
if ( $setting ) {
$setting->transport = 'postMessage';
}
}
}
add_action( 'customize_register', 'customify_color_palette_force_postmessage', 1000 );
}
// ──────────────────────────────────────────────────────────────────
// Preview JS: live-update --customify-<slot> on document.documentElement
// when any of the 6 slot settings changes. Modern browsers re-resolve
// derived tokens (text-muted, border, primary-hover, link-hover) via the
// color-mix() lines in :root automatically. Static fallbacks and on-*
// contrast picks don't live-update — they refresh on Customizer save.
//
// For primary/secondary the auto-CSS pipeline already regenerates the
// rule strings in the preview iframe via the existing Customify auto-css
// JS; this handler additionally keeps the :root var in sync so the
// var(--customify-primary, ...) refactor renders the new color in
// modern browsers.
// ──────────────────────────────────────────────────────────────────
if ( ! function_exists( 'customify_color_palette_preview_js' ) ) {
function customify_color_palette_preview_js() {
$payload = wp_json_encode( array(
'global_styling_color_primary' => '--customify-primary',
'global_styling_color_secondary' => '--customify-secondary',
'customify_palette_accent' => '--customify-accent',
'customify_palette_text' => '--customify-text',
'customify_palette_surface' => '--customify-surface',
'customify_palette_base' => '--customify-base',
// Override-style settings whose CSS rule now consumes the var()
// token. Live-updating the token on drag means the user's direct
// picker change wins over the slot cascade (the :root cascade
// chain `--customify-heading: var(--customify-text, ...)` is
// overridden by the inline style.setProperty we do here). When
// the user clears the picker, our `normalize()` returns '' and
// removeProperty() restores the cascade.
'global_styling_color_heading' => '--customify-heading',
'global_styling_color_text' => '--customify-body-text',
'global_styling_color_link' => '--customify-link',
'global_styling_color_link_hover' => '--customify-link-hover',
'global_styling_color_border' => '--customify-border',
'global_styling_color_meta' => '--customify-text-muted',
'global_styling_color_w_title' => '--customify-widget-title',
) );
$script = "(function(){
if (typeof wp === 'undefined' || ! wp.customize) return;
var SLOT_VARS = {$payload};
// Cascade expressions for derived override tokens. When the user CLEARS
// an override picker mid-session via .set(''), removing the inline style
// would fall through to the PHP-baked :root rule — which still holds the
// SAVED override hex (palette-tokens block is rendered once at page load,
// not regenerated on setting change). Result: cleared overrides keep
// showing the old value until next save+reload.
//
// Fix: when normalize() returns empty for one of these tokens, set the
// inline value to the cascade expression instead of removeProperty. CSS
// custom-property values support nested var()/color-mix(), so the cascade
// chain re-engages immediately and the rendered value tracks the source
// slot in real time. After save+reload, PHP re-renders :root cleanly so
// the inline override is no longer needed.
//
// Tokens NOT in this map fall back to the old removeProperty behavior:
// slot tokens (primary/secondary/accent/text/surface/base) — their
// :root values come from PHP defaults/saved slots, so removing the
// inline override correctly reverts to those.
//
// `--customify-border` IS in this map (since the Phase 2.13 unconditional-
// emit shift): it's always present in :root, so removeProperty would
// fall back to the PHP-baked static line — which still holds the SAVED
// override hex on mid-session clears. The cascade expression here mirrors
// the @supports color-mix line so cleared borders track Text+Base live.
var CASCADE_FALLBACK = {
'--customify-heading': 'var(--customify-text)',
'--customify-body-text': 'var(--customify-text)',
'--customify-widget-title': 'var(--customify-text)',
'--customify-link': 'var(--customify-primary)',
'--customify-link-hover': 'var(--customify-link)',
'--customify-text-muted': 'color-mix(in oklab, var(--customify-text) 70%, var(--customify-base))',
'--customify-border': 'color-mix(in oklab, var(--customify-text) 9%, var(--customify-base))'
};
// Customify wraps stored setting values as urlencode(json_encode(value))
// so a saved hex arrives as '%22#ff00aa%22'. Mirror Customify's decode
// (control.js / customizer.js use the same JSON.parse(decodeURI(v))
// pattern) so we get the raw '#ff00aa' before validating.
function decode(v) {
if (typeof v !== 'string') return v;
try { return JSON.parse(decodeURI(v)); } catch(e) { return v; }
}
function normalize(v) {
var d = decode(v);
if (typeof d !== 'string') return '';
d = d.trim();
if (!d) return '';
if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\$/.test(d)) return d;
if (/^rgba?\\(/.test(d)) return d;
return '';
}
Object.keys(SLOT_VARS).forEach(function(setting){
wp.customize(setting, function(value){
value.bind(function(newval){
var clean = normalize(newval);
var token = SLOT_VARS[setting];
if (clean) {
document.documentElement.style.setProperty(token, clean);
} else if (CASCADE_FALLBACK[token]) {
document.documentElement.style.setProperty(token, CASCADE_FALLBACK[token]);
} else {
document.documentElement.style.removeProperty(token);
}
});
});
});
// Live preview math — mirrors the PHP helpers in colors-palette.php
// per the color-token-derivation spec. Recomputes derived tokens
// (on-*, *-container, on-*-container, border-strong) live as the
// user drags any source-slot picker in the Customizer.
function _hexToRgb(value) {
// Accept both hex (#rrggbb / #rgb) and rgb()/rgba() input forms.
// Mirrors PHP customify_color_hex_to_rgb() — the rgba support is
// what makes on-* live-preview keep working when user drags the
// alpha slider in the WP color picker.
value = (value || '').toString().trim();
// Anchored trailing `)` to reject cut-off inputs (parity with PHP).
var m = value.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*[\d.]+)?\s*\)\$/i);
if (m) {
return [
Math.max(0, Math.min(255, parseInt(m[1], 10))),
Math.max(0, Math.min(255, parseInt(m[2], 10))),
Math.max(0, Math.min(255, parseInt(m[3], 10)))
];
}
var hex = value.replace(/^#/, '');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
if (!/^[0-9a-fA-F]{6}\$/.test(hex)) return null;
return [
parseInt(hex.slice(0,2), 16),
parseInt(hex.slice(2,4), 16),
parseInt(hex.slice(4,6), 16)
];
}
function _rgbToHex(rgb) {
var c = function(v) {
v = Math.max(0, Math.min(255, Math.round(v)));
var h = v.toString(16);
return h.length === 1 ? '0' + h : h;
};
return '#' + c(rgb[0]) + c(rgb[1]) + c(rgb[2]);
}
function _mixHex(a, b, weightA) {
weightA = Math.max(0, Math.min(1, weightA));
var wb = 1 - weightA;
var ra = _hexToRgb(a), rb = _hexToRgb(b);
if (!ra || !rb) return a;
return _rgbToHex([
ra[0]*weightA + rb[0]*wb,
ra[1]*weightA + rb[1]*wb,
ra[2]*weightA + rb[2]*wb
]);
}
function _relativeLuminance(hex) {
var rgb = _hexToRgb(hex);
if (!rgb) return 0;
var f = function(v) {
v = v / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]);
}
function _wcagContrast(a, b) {
var la = _relativeLuminance(a), lb = _relativeLuminance(b);
var hi = Math.max(la, lb), lo = Math.min(la, lb);
return (hi + 0.05) / (lo + 0.05);
}
// Composite a possibly-transparent color over an opaque base. Mirrors
// PHP customify_color_composite_over() — returns the rendered color
// so the max-contrast pick measures against what the user actually
// sees, not the opaque rgb component of an rgba.
function _compositeOver(value, baseHex) {
var v = (value || '').toString().trim();
var m = v.match(/^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([\d.]+)\s*\)/i);
if (m) {
var r = Math.max(0, Math.min(255, parseInt(m[1], 10)));
var g = Math.max(0, Math.min(255, parseInt(m[2], 10)));
var b = Math.max(0, Math.min(255, parseInt(m[3], 10)));
var a = Math.max(0, Math.min(1, parseFloat(m[4])));
if (a >= 1) return [r, g, b];
var baseRgb = _hexToRgb(baseHex) || [255, 255, 255];
return [
Math.round(r * a + baseRgb[0] * (1 - a)),
Math.round(g * a + baseRgb[1] * (1 - a)),
Math.round(b * a + baseRgb[2] * (1 - a))
];
}
// Opaque (hex / rgb passthrough).
return _hexToRgb(v) || [0, 0, 0];
}
// Spec §3: max-contrast pick against the EFFECTIVE (composited) bg.
function _pickOn(value, baseHex) {
baseHex = baseHex || '#FFFFFF';
var rgb = _compositeOver(value, baseHex);
var eff = _rgbToHex(rgb);
return _wcagContrast('#FFFFFF', eff) >= _wcagContrast('#1A1A1A', eff)
? '#FFFFFF' : '#1A1A1A';
}
// OKLab transforms (Ottosson) — same math as PHP customify_color_srgb_to_oklab
// / customify_color_oklab_to_srgb. Needed for §4 P-solve and §5 L-reduction.
function _srgbToOklab(hex) {
var rgb = _hexToRgb(hex);
if (!rgb) return [0, 0, 0];
var f = function(v) {
v = v / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
var rl = f(rgb[0]), gl = f(rgb[1]), bl = f(rgb[2]);
var l = 0.4122214708*rl + 0.5363325363*gl + 0.0514459929*bl;
var m = 0.2119034982*rl + 0.6806995451*gl + 0.1073969566*bl;
var s = 0.0883024619*rl + 0.2817188376*gl + 0.6299787005*bl;
var cbrt = function(x){ return x < 0 ? -Math.pow(-x, 1/3) : Math.pow(x, 1/3); };
var l_ = cbrt(l), m_ = cbrt(m), s_ = cbrt(s);
return [
0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_
];
}
function _oklabToSrgb(L, a, b) {
var l_ = L + 0.3963377774*a + 0.2158037573*b;
var m_ = L - 0.1055613458*a - 0.0638541728*b;
var s_ = L - 0.0894841775*a - 1.2914855480*b;
var l = l_*l_*l_, m = m_*m_*m_, s = s_*s_*s_;
var rl = 4.0767416621*l - 3.3077115913*m + 0.2309699292*s;
var gl = -1.2684380046*l + 2.6097574011*m - 0.3413193965*s;
var bl = -0.0041960863*l - 0.7034186147*m + 1.7076147010*s;
var g = function(v) {
v = Math.max(0, Math.min(1, v));
return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1/2.4) - 0.055;
};
return _rgbToHex([g(rl)*255, g(gl)*255, g(bl)*255]);
}
function _oklabL(hex) { return _srgbToOklab(hex)[0]; }
// Spec §4: closed-form P solve for container tints at OKLab L = 0.93.
function _solveContainerP(source, base) {
var ls = _oklabL(source), lb = _oklabL(base);
var denom = ls - lb;
if (Math.abs(denom) < 1e-6) return 0.5;
var p = (0.93 - lb) / denom;
return Math.max(0.02, Math.min(0.98, p));
}
// Chroma cap (Customify extension) — keeps high-chroma brand
// containers from staying oversaturated when mixed with white.
// Mirrors PHP customify_color_chroma_cap_oklab().
function _chromaCap(hex, maxChroma) {
var lab = _srgbToOklab(hex);
var L = lab[0], a = lab[1], b = lab[2];
var c = Math.sqrt(a*a + b*b);
if (c <= maxChroma) return hex;
var s = maxChroma / c;
return _oklabToSrgb(L, a * s, b * s);
}
// Spec §5: step OKLab L downward until contrast against bg ≥ 4.5.
function _lReduceUntilContrast(source, bg, target) {
target = target || 4.5;
var lab = _srgbToOklab(source);
var L = lab[0], a = lab[1], b = lab[2];
while (L > 0) {
var candidate = _oklabToSrgb(L, a, b);
if (_wcagContrast(candidate, bg) >= target) return candidate;
L -= 0.02;
}
return '#1A1A1A';
}
// Spec §2: iterate P from 6% upward until contrast vs base ≥ 3.0.
function _solveBorderStrong(text, base) {
for (var p = 6; p <= 100; p++) {
var mix = _mixHex(text, base, p/100);
if (_wcagContrast(mix, base) >= 3.0) return mix;
}
return text;
}
// Helper: read a slot's current effective value from wp.customize.
// Falls back to the spec default when the user hasn't saved the slot
// (mirrors PHP slot resolver). Used by recomputeDerived() so a
// container update fires correctly even mid-drag of a non-source slot.
var SLOT_DEFAULTS = {
'global_styling_color_primary': '#0e7c7b',
'global_styling_color_secondary': '#c3512f',
'customify_palette_accent': '#FFD042',
'customify_palette_text': '#2b2b2b',
'customify_palette_surface': '#f9f9f9',
'customify_palette_base': '#FFFFFF'
};
function _readSlot(setting) {
try {
var val = wp.customize(setting).get();
var clean = normalize(val);
return clean || SLOT_DEFAULTS[setting];
} catch(e) {
return SLOT_DEFAULTS[setting];
}
}
// Recompute every derived token. Called from every source-slot
// listener so a drag on Primary updates container/on-container/etc.
function recomputeDerived() {
var primary = _readSlot('global_styling_color_primary');
var secondary = _readSlot('global_styling_color_secondary');
var accent = _readSlot('customify_palette_accent');
var text = _readSlot('customify_palette_text');
var surface = _readSlot('customify_palette_surface');
var base = _readSlot('customify_palette_base');
var de = document.documentElement.style;
// §3 on-* — max-contrast against rgba-composited bg.
de.setProperty('--customify-on-primary', _pickOn(primary, base));
de.setProperty('--customify-on-secondary', _pickOn(secondary, base));
de.setProperty('--customify-on-accent', _pickOn(accent, base));
// On-surface uses #FFFFFF when --customify-surface is unset
// (matches the SCSS var() fallback in `.is-style-card`).
var hasSurface = false;
try { hasSurface = !!normalize(wp.customize('customify_palette_surface').get()); } catch(e) {}
de.setProperty('--customify-on-surface', _pickOn(hasSurface ? surface : '#FFFFFF', base));
// §4 *-container — solve P, mix, apply chroma cap (0.04). Emit
// as static hex (NOT a color-mix expression) because the chroma
// cap can't be expressed in CSS color-mix. Mirrors PHP container
// emit logic.
var CONTAINER_MAX_CHROMA = 0.04;
var pPrim = _solveContainerP(primary, base);
var pSec = _solveContainerP(secondary, base);
var pAcc = _solveContainerP(accent, base);
var primContainerHex = _chromaCap(_mixHex(primary, base, pPrim), CONTAINER_MAX_CHROMA);
var secContainerHex = _chromaCap(_mixHex(secondary, base, pSec), CONTAINER_MAX_CHROMA);
var accContainerHex = _chromaCap(_mixHex(accent, base, pAcc), CONTAINER_MAX_CHROMA);
de.setProperty('--customify-primary-container', primContainerHex);
de.setProperty('--customify-secondary-container', secContainerHex);
de.setProperty('--customify-accent-container', accContainerHex);
// §5 on-*-container — L-reduced brand hue against the resolved
// CAPPED container hex (so the safety net matches what's rendered).
de.setProperty('--customify-on-primary-container', _lReduceUntilContrast(primary, primContainerHex));
de.setProperty('--customify-on-secondary-container', _lReduceUntilContrast(secondary, secContainerHex));
de.setProperty('--customify-on-accent-container', _lReduceUntilContrast(accent, accContainerHex));
// §2 border-strong — solved P.
de.setProperty('--customify-border-strong', _solveBorderStrong(text, base));
}
// Hook recomputeDerived to every source-slot change. Each derived
// token depends on at least one source, so any drag fires a full
// recompute (cheap — all formulas are closed-form or short loops).
var SOURCE_SLOTS = [
'global_styling_color_primary',
'global_styling_color_secondary',
'customify_palette_accent',
'customify_palette_text',
'customify_palette_surface',
'customify_palette_base'
];
// Debounce: a palette switch changes all six slots at once; binding the raw
// recomputeDerived would run the OKLab derivation six times back-to-back and
// block the preview paint (~100ms+). Coalesce into ONE recompute ~24ms after
// the last slot settles — imperceptible for a drag, snappy for a switch.
var _cfyRderiveT;
function recomputeDerivedDebounced(){ clearTimeout(_cfyRderiveT); _cfyRderiveT = setTimeout(recomputeDerived, 24); }
SOURCE_SLOTS.forEach(function(setting){
wp.customize(setting, function(value){
value.bind(recomputeDerivedDebounced);
});
});
// Prime the initial state on load so the preview iframe shows
// derived values immediately (without waiting for the first drag).
try { recomputeDerived(); } catch(e) {}
})();";
wp_add_inline_script( 'customize-preview', $script );
}
add_action( 'customize_preview_init', 'customify_color_palette_preview_js' );
}
// ──────────────────────────────────────────────────────────────────
// theme.json palette injection — make Customify Palette tokens
// available to the block editor color picker (WP core, Blocksify,
// Gutenberg blocks, child themes, etc.) via `--wp--preset--color--X`.
// ──────────────────────────────────────────────────────────────────
//
// Why: block editor color pickers (and Blocksify's ColorSections
// composite) read the palette via `useSetting('color.palette.theme')`,
// which returns whatever theme.json declares under
// settings.color.palette. Without this filter the static palette in
// theme.json contains hard-coded hex values — block previews don't
// reflect the saved Customizer Palette, and authors picking "Primary"
// in the editor get the literal #235787 baked in at theme.json author
// time instead of the user's currently-saved primary slot value.
//
// What: replace the palette at runtime with the same slugs but each
// `color` field reads a `var(--customify-X, {literal_hex_fallback})`
// reference. WP 6.1+ accepts var() in palette color → propagates the
// var into the generated `--wp--preset--color--X` declaration → user
// blocks that picked "Primary" automatically follow the saved
// Customizer Primary, no rebuild required.
//
// 30K safety: the literal hex fallback inside each var() expression
// is the SAME value that the static theme.json palette previously
// declared. Sites with no saved Palette resolve the chain to the
// literal — byte-identical block render to before the filter.
//
// WP version gate: var() in palette color is WP 6.1+. Older WP
// renders the swatch as a raw "var(--customify-...)" text string in
// the picker (broken UX). Early-return on <6.1 keeps the static
// theme.json palette intact for those sites.
if ( ! function_exists( 'customify_color_palette_for_theme_json' ) ) {
/**
* Build the palette array consumed by `wp_theme_json_data_theme`.
*
* Each entry uses `var(--customify-{token}, {hex_fallback})` so the
* block editor picker swatch + every block that picks this slug
* track the live Customizer value when set, and fall back to the
* legacy hex (byte-identical to the pre-filter render) when not.
*
* Token sources:
* • Static slots (always emitted to :root): base / surface / text
* / primary / secondary / accent / link / heading / text-muted
* / link-hover / primary-hover.
* • Gated slots (emitted only on palette opt-in): on-primary /
* on-secondary / on-accent / on-surface / border / surface.
*
* Existing slugs (already in static theme.json) preserve their
* legacy hex fallback so currently-rendered block colors don't
* shift. New slugs (link-hover, primary-hover, text-muted, border,
* on-*) use the PHP-computed default from `customify_color_*`
* helpers as the var() fallback.
*
* @return array
*/
function customify_color_palette_for_theme_json() {
// Slot values resolve to: saved theme_mod, else slot default.
// We use these to seed the var() fallback for derived slugs so
// the literal in the fallback matches what the bundled SCSS
// would compute for an unsaved site.
$slots = customify_color_get_slots();
// Precompute fallback hexes per the color-token-derivation spec
// formulas. Fresh sites (no Palette opt-in) get the literal hex
// inside `var(--customify-X, {hex})` — block authors still see
// concrete swatches in the picker. Opt-in sites resolve through
// to the live Customizer value via the cascade.
$text_muted_hex = customify_color_mix_hex( $slots['text'], $slots['base'], 0.70 );
$border_hex = customify_color_mix_hex( $slots['text'], $slots['base'], 0.09 ); // 9% — see border_default in customify_color_palette_root_css(); matches grayscale equivalent of legacy #eaecee
$border_strong_hex = customify_color_solve_border_strong( $slots['text'], $slots['base'] );
// Container fallbacks — solve P per spec §4, mix, then apply the
// chroma cap (0.04) so the picker swatch matches what the :root
// emits. High-chroma brands (yellow / lime / hot pink) would
// otherwise yield oversaturated container tints (e.g. accent yellow
// → bright #ffe595 instead of soft cream #f0e6c9).
$container_max_chroma = 0.04;
$p_primary = customify_color_solve_container_p( $slots['primary'], $slots['base'] );
$p_secondary = customify_color_solve_container_p( $slots['secondary'], $slots['base'] );
$p_accent = customify_color_solve_container_p( $slots['accent'], $slots['base'] );
$container_p_hex = customify_color_chroma_cap_oklab( customify_color_mix_hex( $slots['primary'], $slots['base'], $p_primary ), $container_max_chroma );
$container_s_hex = customify_color_chroma_cap_oklab( customify_color_mix_hex( $slots['secondary'], $slots['base'], $p_secondary ), $container_max_chroma );
$container_a_hex = customify_color_chroma_cap_oklab( customify_color_mix_hex( $slots['accent'], $slots['base'], $p_accent ), $container_max_chroma );
// Lean 12-entry palette — every slug a real design choice a
// block author would reach for. Pair-order layout so each brand
// color sits next to its container partner in the picker:
//
// Primary · Primary Container · Secondary · Secondary Container
// · Accent · Accent Container · Text · Surface · Base
// · Text Muted · Border · Border Strong
//
// Hidden from picker (still emitted to :root as theme internals):
// on-primary, on-secondary, on-accent, on-surface,
// on-primary-container, on-secondary-container, on-accent-container.
//
// The on-* family is for theme CSS rules (button text, .is-style-card
// foreground safety net, etc.) — block authors typically don't pick
// "text on primary" from a palette swatch; they use the source
// brand color directly as text on the container, and theme button
// rules auto-apply on-* via `.has-X-background-color` matchers.
//
// Deliberately omitted from the picker entirely:
// • link / link-hover / heading — handled by global styles.
// • primary-hover — hover STATE; CSS pseudo-class territory.
// • background / light-gray / dark-gray — legacy noise.
return array(
// ─── Brand + container pairs (per Material Design 3).
array(
'slug' => 'primary',
'name' => __( 'Primary', 'customify' ),
'color' => 'var(--customify-primary, ' . $slots['primary'] . ')',
),
array(
'slug' => 'primary-container',
'name' => __( 'Primary Container', 'customify' ),
// Soft tint of primary (spec §4). Default palette → ~light
// blue-grey; user-saved primary → tinted accordingly.
'color' => 'var(--customify-primary-container, ' . $container_p_hex . ')',
),
array(
'slug' => 'secondary',
'name' => __( 'Secondary', 'customify' ),
'color' => 'var(--customify-secondary, ' . $slots['secondary'] . ')',
),
array(
'slug' => 'secondary-container',
'name' => __( 'Secondary Container', 'customify' ),
'color' => 'var(--customify-secondary-container, ' . $container_s_hex . ')',
),
array(
'slug' => 'accent',
'name' => __( 'Accent', 'customify' ),
'color' => 'var(--customify-accent, ' . $slots['accent'] . ')',
),
array(
'slug' => 'accent-container',
'name' => __( 'Accent Container', 'customify' ),
'color' => 'var(--customify-accent-container, ' . $container_a_hex . ')',
),
// ─── Text + canvas axis.
// Slug intentionally named `body-text` NOT `text` to avoid the
// `.has-text-color` collision: WP auto-generates
// `.has-{slug}-color { color: var(--wp--preset--color--{slug}) !important; }`
// for every palette slug. When the slug is literally `text`,
// the generated rule (`.has-text-color { color: var(--text) !important }`)
// matches the WP marker class that's added to ANY block whose
// text color was set manually (inline style or a different
// slug pick) — overriding the user's actual color choice with
// the text slot value. Renaming the slug to `body-text` keeps
// the picker swatch labelled "Body Text" while sidestepping
// the marker-class collision. The CSS var() target stays
// `--customify-text` (the underlying slot key is unchanged).
array(
'slug' => 'body-text',
'name' => __( 'Body Text', 'customify' ),
'color' => 'var(--customify-text, ' . $slots['text'] . ')',
),
array(
'slug' => 'surface',
'name' => __( 'Surface', 'customify' ),
'color' => 'var(--customify-surface, ' . $slots['surface'] . ')',
),
array(
'slug' => 'base',
'name' => __( 'Base', 'customify' ),
'color' => 'var(--customify-base, ' . $slots['base'] . ')',
),
// ─── Derived / chrome.
array(
'slug' => 'text-muted',
'name' => __( 'Text Muted', 'customify' ),
// Derived from text + base (spec §2). Auto-updates when
// Text or Base changes.
'color' => 'var(--customify-text-muted, ' . $text_muted_hex . ')',
),
// Slugs `divider` / `divider-strong` are renamed from spec's
// `border` / `border-strong` to dodge the same WP marker-class
// collision that hit the `text` slug (see body-text note above):
// WP adds `has-border-color` as a marker class to ANY block whose
// border color was set via the Border panel — so a generated
// `.has-border-color { color: var(--border) !important }` rule
// would force the text color to the border value on every
// border-styled block. The :root token names (--customify-border,
// --customify-border-strong) stay unchanged because they're
// theme-internal — only the picker slug is renamed.
array(
'slug' => 'divider',
'name' => __( 'Divider', 'customify' ),
// Decorative-only (~1.35:1 vs base, WCAG-exempt). For
// functional control boundaries (form input borders,
// button outlines) use `divider-strong`. Spec §2.
'color' => 'var(--customify-border, ' . $border_hex . ')',
),
array(
'slug' => 'divider-strong',
'name' => __( 'Divider Strong', 'customify' ),
// WCAG 1.4.11 ≥3:1 vs base — for form input borders and
// any boundary that's the ONLY cue identifying a control.
// Spec §2 (solved P).
'color' => 'var(--customify-border-strong, ' . $border_strong_hex . ')',
),
);
// ───────────────────────────────────────────────────────────
// Legacy slugs (text / link / heading / background / light-gray /
// dark-gray) — DELIBERATELY OMITTED per project owner decision.
//
// The original static theme.json palette listed these slugs and
// 30K sites with block markup `class="has-{slug}-color"` rely
// on the WP-generated `--wp--preset--color--{slug}` declarations
// + `.has-{slug}-color { color: var(...) !important }` rules.
// Removing the slugs means those rules are no longer emitted,
// so legacy block markup falls back to the body text cascade
// (loses the explicitly-picked color).
//
// Trade-off explicitly accepted by the project owner: clean
// picker UX wins over backward compat. Old block markup that
// loses color can be manually re-picked using one of the 12
// design-purposeful slugs above (e.g. `text` → `body-text`,
// `heading` → `body-text` or whatever the designer intended).
//
// This is a CONSCIOUS regression. Documented as such in
// SPEC §3.7 + §8.11. Sites that need the legacy slugs back
// can override the filter via wp_theme_json_data_theme
// (priority > default) and re-add them.
}
}
if ( ! function_exists( 'customify_palette_inject_into_theme_json' ) ) {
/**
* Replace the theme.json color palette at runtime with the
* Customify dynamic palette. Hooks `wp_theme_json_data_theme`
* which fires BEFORE WP renders `--wp--preset--color--*` so the
* generated declarations carry the var() reference (not a frozen
* hex snapshot).
*
* The replacement is conditional:
* • WP <6.1: skip (var() in palette color unsupported; would
* render raw "var(--customify-X)" text in the picker).
* • WP >=6.1: replace the entire `settings.color.palette` array.
*
* @param WP_Theme_JSON_Data $theme_json
* @return WP_Theme_JSON_Data
*/
function customify_palette_inject_into_theme_json( $theme_json ) {
if ( version_compare( get_bloginfo( 'version' ), '6.1', '<' ) ) {
return $theme_json;
}
$palette = customify_color_palette_for_theme_json();
// update_with() merges; passing the palette array under the
// same path replaces it wholesale (arrays in theme.json don't
// merge per-element, they replace).
$theme_json->update_with( array(
'version' => 3,
'settings' => array(
'color' => array(
'palette' => $palette,
),
),
) );
return $theme_json;
}
add_filter( 'wp_theme_json_data_theme', 'customify_palette_inject_into_theme_json' );
}