= 0 && $luminance < 0.45; } /** * WCAG contrast ratio between two colours. * * @param string $first * @param string $second * @return float 1-21, or 0 when either colour cannot be read. */ public static function contrast($first, $second): float { $firstLuminance = self::luminance($first); $secondLuminance = self::luminance($second); if ($firstLuminance < 0 || $secondLuminance < 0) { return 0.0; } $lighter = max($firstLuminance, $secondLuminance); $darker = min($firstLuminance, $secondLuminance); return ($lighter + 0.05) / ($darker + 0.05); } /** * Pick whichever of two text colours reads better on a background. * * @param string $background * @param string $light * @param string $dark * @return string */ public static function readableOn($background, string $light = '#ffffff', string $dark = '#1f2937'): string { return self::contrast($background, $light) >= self::contrast($background, $dark) ? $light : $dark; } /** * A text colour that reads on a background (WCAG 4.5:1) wherever one can. * * readableOn()'s usual pair is kept whenever it reads, so every partner it * already made readable is unchanged. On a mid-tone background neither * half of that pair reaches 4.5:1 (#809400 peaks at 4.30:1 with #1f2937); * pure black or white — whichever reads better — goes further, and is * taken only when it does. * * @param string $background * @return string */ public static function readableText($background): string { $picked = self::readableOn($background); $pickedContrast = self::contrast($background, $picked); if ($pickedContrast >= 4.5) { return $picked; } $extreme = self::contrast($background, '#ffffff') >= self::contrast($background, '#000000') ? '#ffffff' : '#000000'; return self::contrast($background, $extreme) > $pickedContrast ? $extreme : $picked; } /** * Mix text into a surface, keeping as little of the text as still reads. * * Starts at $percent of the text and moves toward it in small steps until * the mix reaches $minimum contrast on the surface — a mix that already * reads is returned unchanged. Text that does not read on the surface * itself comes back as the text: nothing between the two reads better. * * @param string $text * @param string $surface * @param int $percent Starting share of the text, 0-100. * @param float $minimum * @return string Hex, or '' when either colour is unreadable. */ public static function readableMix($text, $surface, int $percent, float $minimum = 4.5): string { if (!self::parse($text) || !self::parse($surface)) { return ''; } for ($share = max(0, $percent); $share < 100; $share += 2) { $mixed = self::mix($text, $surface, $share); if (self::contrast($surface, $mixed) >= $minimum) { return $mixed; } } return self::hex($text); } /** * Move a colour away from itself — lighter when it is dark, darker when it * is light. How a hover is derived from a button that states none. * * @param string $color * @param int $percent * @return string */ public static function shiftFromItself($color, int $percent = 12): string { return self::isDark($color) ? self::lighten($color, $percent) : self::darken($color, $percent); } /** * Nudge a colour toward white. * * @param string $color * @param int $percent * @return string */ public static function lighten($color, int $percent): string { return self::mix('#ffffff', $color, $percent); } /** * Nudge a colour toward black. * * @param string $color * @param int $percent * @return string */ public static function darken($color, int $percent): string { return self::mix('#000000', $color, $percent); } /** * Move a colour away from the surface it sits on, so it stays visible on * a light or a dark background without the caller knowing which it has. * * @param string $color * @param string $background * @param int $percent * @return string */ public static function awayFrom($color, $background, int $percent): string { return self::isDark($background) ? self::lighten($color, $percent) : self::darken($color, $percent); } }