label. Stored as appearance.colors.{light|dark}.{slot}. */ public const SLOTS = [ 'panel_bg' => 'Panel background', 'header_bg' => 'Header background', 'header_fg' => 'Header text', 'user_bg' => 'Visitor message background', 'user_fg' => 'Visitor message text', 'bot_bg' => 'Assistant message background', 'bot_fg' => 'Assistant message text', 'composer_bg' => 'Message box background', 'composer_fg' => 'Message box text', 'button_bg' => 'Primary button background', 'button_fg' => 'Primary button text and icon', 'launcher_bg' => 'Launcher button background', 'launcher_fg' => 'Launcher logo backing', 'link' => 'Links and focus outline', ]; public const SCHEMES = ['light', 'dark']; /** [foreground slot, background slot, minimum ratio]: text 4.5:1, non-text UI parts 3:1. */ public const PAIRS = [ ['header_fg', 'header_bg', 4.5], ['user_fg', 'user_bg', 4.5], ['bot_fg', 'bot_bg', 4.5], ['composer_fg', 'composer_bg', 4.5], ['button_fg', 'button_bg', 4.5], ['launcher_fg', 'launcher_bg', 3.0], ['link', 'panel_bg', 4.5], ]; /** * @return array{light: array, dark: array} only the set slots * @throws \InvalidArgumentException on an unknown scheme or slot, or a value that is not #rrggbb */ public static function normalize(mixed $input): array { $out = ['light' => [], 'dark' => []]; if ($input === null || $input === '' || $input === []) { return $out; } if (!is_array($input)) { throw new \InvalidArgumentException('Invalid colours.'); } foreach ($input as $scheme => $slots) { if (!in_array($scheme, self::SCHEMES, true) || !is_array($slots)) { throw new \InvalidArgumentException('Unknown colour scheme.'); } foreach ($slots as $slot => $value) { if (!isset(self::SLOTS[$slot]) || !is_string($value)) { throw new \InvalidArgumentException('Unknown colour slot.'); } $value = strtolower(trim($value)); if ($value === '') { continue; } if (!self::isHex($value)) { throw new \InvalidArgumentException('Colours must be 6-digit HEX values such as #1a2b3c.'); } $out[$scheme][$slot] = $value; } } return $out; } public static function isHex(string $value): bool { return preg_match('/^#[0-9a-f]{6}$/D', strtolower($value)) === 1; } /** The widget's built-in colours for a scheme, given the brand accent (mirrors chat-widget.css). */ public static function defaults(string $scheme, string $accent): array { $accent = self::isHex($accent) ? strtolower($accent) : '#4f46e5'; if ($scheme === 'dark') { $panel = '#16191f'; return [ 'panel_bg' => $panel, 'header_bg' => $panel, 'header_fg' => '#eceff4', 'user_bg' => self::mix($accent, $panel, .72), 'user_fg' => '#ffffff', 'bot_bg' => '#1f232b', 'bot_fg' => '#eceff4', 'composer_bg' => $panel, 'composer_fg' => '#eceff4', 'button_bg' => $accent, 'button_fg' => '#ffffff', 'launcher_bg' => $accent, 'launcher_fg' => '#ffffff', 'link' => self::mix($accent, '#ffffff', .45), ]; } return [ 'panel_bg' => '#ffffff', 'header_bg' => '#ffffff', 'header_fg' => '#151b2c', 'user_bg' => self::mix($accent, '#ffffff', .11), 'user_fg' => '#151b2c', 'bot_bg' => '#f5f6fa', 'bot_fg' => '#151b2c', 'composer_bg' => '#ffffff', 'composer_fg' => '#151b2c', 'button_bg' => $accent, 'button_fg' => '#ffffff', 'launcher_bg' => $accent, 'launcher_fg' => '#ffffff', 'link' => $accent, ]; } /** * Pairs below their minimum contrast, with the effective colours and a readable alternative * for the foreground (black-ish or white, whichever reads better), or null if neither does. * * @param array $set the scheme's set slots * @return list */ public static function warnings(string $scheme, array $set, string $accent): array { $c = $set + self::defaults($scheme, $accent); // Unset header/composer backgrounds follow the panel background. foreach (['header_bg', 'composer_bg'] as $s) { if (!isset($set[$s]) && isset($set['panel_bg'])) { $c[$s] = $set['panel_bg']; } } $out = []; foreach (self::PAIRS as [$fg, $bg, $min]) { $ratio = self::contrast($c[$fg], $c[$bg]); if ($ratio + 1e-9 >= $min) { continue; } $dark = self::contrast('#151b2c', $c[$bg]); $light = self::contrast('#ffffff', $c[$bg]); $best = $dark >= $light ? '#151b2c' : '#ffffff'; $out[] = ['fg' => $fg, 'bg' => $bg, 'ratio' => round($ratio, 2), 'min' => $min, 'fg_color' => $c[$fg], 'bg_color' => $c[$bg], 'suggestion' => max($dark, $light) >= $min ? $best : null]; } return $out; } /** WCAG 2 contrast ratio of two #rrggbb colours (1–21). */ public static function contrast(string $a, string $b): float { $la = self::luminance($a); $lb = self::luminance($b); return (max($la, $lb) + .05) / (min($la, $lb) + .05); } private static function luminance(string $hex): float { $l = 0.0; foreach ([[1, .2126], [3, .7152], [5, .0722]] as [$at, $w]) { $v = hexdec(substr($hex, $at, 2)) / 255; $l += $w * ($v <= .04045 ? $v / 12.92 : (($v + .055) / 1.055) ** 2.4); } return $l; } /** sRGB mix like CSS color-mix(in srgb, $a $p, $b). */ public static function mix(string $a, string $b, float $p): string { $out = '#'; foreach ([1, 3, 5] as $at) { $out .= sprintf('%02x', (int) round(hexdec(substr($a, $at, 2)) * $p + hexdec(substr($b, $at, 2)) * (1 - $p))); } return $out; } }