330 || $saturation < .035 ) { return $color; } if ( $role === 'text' ) { return $light > .68 ? '#e6f8f2' : ( $light < .25 ? self::INK : self::LINK ); } if ( $saturation < .18 ) { return $light > .93 ? '#f5f9f8' : ( $light > .78 ? '#dce9e5' : ( $light > .55 ? '#91a6a2' : ( $light > .28 ? '#5e7274' : self::INK ) ) ); } return $light > .84 ? '#e6f8f2' : ( $light > .68 ? '#bfebdf' : ( $light < .25 ? self::INK : self::MINT ) ); } private static function luminance( string $color ): float { $rgb = self::rgb( $color ) ?? array( 0, 0, 0 ); $linear = array_map( static fn( float $v ): float=>$v <= .04045 ? $v / 12.92 : pow( ( $v + .055 ) / 1.055, 2.4 ), $rgb ); return .2126 * $linear[0] + .7152 * $linear[1] + .0722 * $linear[2]; } public static function contrast( string $a, string $b ): float { $x = self::luminance( $a ); $y = self::luminance( $b ); return( max( $x, $y ) + .05 ) / ( min( $x, $y ) + .05 ); } public static function on_primary( string $primary ): string { return self::contrast( $primary, self::INK ) > self::contrast( $primary, '#ffffff' ) ? self::INK : '#ffffff'; } public static function text( string $primary ): string { return self::contrast( $primary, '#ffffff' ) >= 4.5 ? $primary : self::LINK; } /** Hex for 0–1 RGB channels. */ private static function hex( array $rgb ): string { return sprintf( '#%02x%02x%02x', ...array_map( static fn( float $v ): int=> (int) round( max( 0, min( 1, $v ) ) * 255 ), $rgb ) ); } private static function hsl( array $rgb ): array { [$r, $g, $b] = $rgb; $max = max( $rgb ); $min = min( $rgb ); $l = ( $max + $min ) / 2; $d = $max - $min; if ( $d <= 0 ) { return array( 0.0, 0.0, $l ); } $s = $d / ( 1 - abs( 2 * $l - 1 ) ); $h = $max === $r ? fmod( ( $g - $b ) / $d, 6 ) : ( $max === $g ? ( $b - $r ) / $d + 2 : ( $r - $g ) / $d + 4 ); $h *= 60; if ( $h < 0 ) { $h += 360; } return array( $h, $s, $l ); } private static function from_hsl( float $h, float $s, float $l ): array { $c = ( 1 - abs( 2 * $l - 1 ) ) * $s; $x = $c * ( 1 - abs( fmod( $h / 60, 2 ) - 1 ) ); $m = $l - $c / 2; $i = (int) floor( $h / 60 ) % 6; $set = array( array( $c, $x, 0 ), array( $x, $c, 0 ), array( 0, $c, $x ), array( 0, $x, $c ), array( $x, 0, $c ), array( $c, 0, $x ) )[ $i ]; return array( $set[0] + $m, $set[1] + $m, $set[2] + $m ); } /** Mixes two hex colors; $weight is the share of $a. */ public static function mix( string $a, string $b, float $weight ): string { $x = self::rgb( $a ); $y = self::rgb( $b ); if ( ! $x || ! $y ) { return $a; } return self::hex( array_map( static fn( float $p, float $q ): float=>$p * $weight + $q * ( 1 - $weight ), $x, $y ) ); } /** * Automatic text color: keeps the hue and saturation of $color and moves only its lightness, * as little as possible, until it reaches $target contrast (WCAG) against $background. * Mirrored by assets/frontend/contrast.js for the preview and see-through forms. */ public static function readable( string $color, string $background, float $target = 4.5 ): string { $rgb = self::rgb( $color ); if ( ! $rgb || ! self::rgb( $background ) ) { return $color; } if ( self::contrast( $color, $background ) >= $target ) { return $color; } [$h, $s, $l] = self::hsl( $rgb ); $darken = self::contrast( '#000000', $background ) >= self::contrast( '#ffffff', $background ); $extreme = self::hex( self::from_hsl( $h, $s, $darken ? 0.0 : 1.0 ) ); if ( self::contrast( $extreme, $background ) < $target ) { return $darken ? '#000000' : '#ffffff'; } $near = $l; $far = $darken ? 0.0 : 1.0;for ( $i = 0;$i < 24;$i++ ) { $mid = ( $near + $far ) / 2; if ( self::contrast( self::hex( self::from_hsl( $h, $s, $mid ) ), $background ) >= $target ) { $far = $mid; } else { $near = $mid; } } return self::hex( self::from_hsl( $h, $s, $far ) ); } /** Secondary text: closer to the background, still readable. */ public static function readable_muted( string $text, string $background ): string { return self::readable( self::mix( $text, $background, .72 ), $background ); } public static function foreground( string $color, string $background ): string { $paint = self::color( $color, 'text' ); $surface = self::color( $background ); return( $paint !== $color || $surface !== $background ) && self::contrast( $paint, $surface ) < 4.5 ? self::on_primary( $surface ) : $paint; } }