$accent, 'header' => $header, 'header_color' => $header_color, 'mode' => $mode, ); } /** * CSS custom properties derived from the theme settings, ready to emit * on the widget root. Keys are var names (incl. leading --), values are * CSS values. * * @return array */ public static function css_variables(): array { $s = self::settings(); $accent = $s['accent']; $vars = array( '--ar-accent' => $accent, '--ar-accent-hover' => self::shade($accent, -0.12), '--ar-accent-contrast' => self::text_on($accent), '--ar-focus-ring' => self::rgba($accent, 0.12), '--ar-focus-border' => $accent, ); switch ($s['header']) { case 'surface': // Plain surface header: window background + standard text. // The stylesheet's dark-mode overrides restyle it there. $vars['--ar-header-bg'] = 'var(--ar-bg)'; $vars['--ar-header-text'] = 'var(--ar-text)'; $vars['--ar-header-text-soft'] = 'var(--ar-text-muted)'; break; case 'custom': $base = $s['header_color']; $vars['--ar-header-bg'] = sprintf( 'linear-gradient(135deg, %s 0%%, %s 100%%)', $base, self::shade($base, -0.12) ); $vars['--ar-header-text'] = self::text_on($base); $vars['--ar-header-text-soft'] = self::rgba(self::text_on($base), 0.9); break; case 'accent': default: $vars['--ar-header-bg'] = sprintf( 'linear-gradient(135deg, %s 0%%, %s 100%%)', $accent, self::shade($accent, -0.12) ); $vars['--ar-header-text'] = self::text_on($accent); $vars['--ar-header-text-soft'] = self::rgba(self::text_on($accent), 0.9); break; } return $vars; } /** * Inline style attribute value for the widget root (the same injection * pattern the launcher options already use). */ public static function inline_style(): string { $pairs = array(); foreach (self::css_variables() as $name => $value) { $pairs[] = $name . ':' . $value; } return implode('; ', $pairs) . ';'; } /** * The data-ar-mode attribute value ('light' | 'dark' | 'auto'); the * stylesheet's dark token overrides key off it. */ public static function mode(): string { $s = self::settings(); return $s['mode']; } /* --------------------------------------------------------------------- * Colour math (pure; unit-tested) * ------------------------------------------------------------------- */ /** * Parse #rgb / #rrggbb into [r, g, b] (0–255). Null on garbage. * * @return array{0:int,1:int,2:int}|null */ public static function parse_hex(string $hex) { $hex = ltrim(trim($hex), '#'); if (preg_match('/^[0-9a-f]{3}$/i', $hex)) { return array( (int) hexdec(str_repeat($hex[0], 2)), (int) hexdec(str_repeat($hex[1], 2)), (int) hexdec(str_repeat($hex[2], 2)), ); } if (preg_match('/^[0-9a-f]{6}$/i', $hex)) { return array( (int) hexdec(substr($hex, 0, 2)), (int) hexdec(substr($hex, 2, 2)), (int) hexdec(substr($hex, 4, 2)), ); } return null; } /** * WCAG 2.x relative luminance (0 = black, 1 = white). */ public static function luminance(string $hex): float { $rgb = self::parse_hex($hex); if (null === $rgb) { return 0.0; } $channels = array(); foreach ($rgb as $channel) { $c = $channel / 255; $channels[] = ($c <= 0.03928) ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4); } return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; } /** * WCAG 2.x contrast ratio between two colours (1.0–21.0). #122 (D6): * the plugin's single contrast authority — text_on() and the admin * contrast guard both resolve through it; never re-derive elsewhere. */ public static function contrast_ratio(string $hex_a, string $hex_b): float { $la = self::luminance($hex_a); $lb = self::luminance($hex_b); $lighter = max($la, $lb); $darker = min($la, $lb); return ($lighter + 0.05) / ($darker + 0.05); } /** * Readable text colour for a given background: whichever of the two * text colours has the HIGHER WCAG contrast ratio against it (#122 D6). * A luminance-threshold approximation is not equivalent — it picks the * lower-contrast side near the boundary. The default accent (#2563eb) * selects white (≈5.17:1 vs ≈3.35:1). */ public static function text_on(string $bg_hex): string { $dark = self::contrast_ratio($bg_hex, self::TEXT_DARK); $light = self::contrast_ratio($bg_hex, self::TEXT_LIGHT); return ($dark >= $light) ? self::TEXT_DARK : self::TEXT_LIGHT; } /** * Nearest shade of $hex whose BEST available text colour meets the * target contrast ratio. #122 (D5): powers the contrast guard's * one-click fix when the colour itself is the problem (both text * options fail). Walks outward in 5% shade steps, smallest adjustment * wins; terminates by construction (black and white both pass 4.5). */ public static function nearest_compliant(string $hex, float $target = 4.5): string { if (self::contrast_ratio($hex, self::text_on($hex)) >= $target) { return $hex; } for ($i = 1; $i <= 20; $i++) { $amount = $i * 0.05; foreach (array(-$amount, $amount) as $a) { $candidate = self::shade($hex, $a); if (self::contrast_ratio($candidate, self::text_on($candidate)) >= $target) { return $candidate; } } } return '#000000'; // Unreachable: shade(±1) is pure black/white. } /** * Darken (negative amount) or lighten (positive) a colour by a fraction * of the distance to black/white. Amount clamped to [-1, 1]. */ public static function shade(string $hex, float $amount): string { $rgb = self::parse_hex($hex); if (null === $rgb) { return $hex; } $amount = max(-1.0, min(1.0, $amount)); $out = array(); foreach ($rgb as $channel) { if ($amount < 0) { $value = $channel * (1 + $amount); // toward black } else { $value = $channel + (255 - $channel) * $amount; // toward white } $out[] = (int) max(0, min(255, round($value))); } return sprintf('#%02x%02x%02x', $out[0], $out[1], $out[2]); } /** * rgba() string for a hex colour at the given alpha. */ public static function rgba(string $hex, float $alpha): string { $rgb = self::parse_hex($hex); if (null === $rgb) { $rgb = array(0, 0, 0); } $alpha = max(0.0, min(1.0, $alpha)); return sprintf('rgba(%d, %d, %d, %s)', $rgb[0], $rgb[1], $rgb[2], rtrim(rtrim(number_format($alpha, 2, '.', ''), '0'), '.')); } }