severityDecode(hexdec($pair) / 255), str_split($hex[1], 2), ); } if (preg_match('/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*\)$/', $value, $m)) { return severityOklchToLinear((float) $m[1] / 100, (float) $m[2], (float) $m[3]); } throw new InvalidArgumentException("Unsupported color: {$value}"); } function severityInGamut(array $rgb): bool { foreach ($rgb as $channel) { if ($channel < -0.0005 || $channel > 1.0005) { return false; } } return true; } function severityLuminance(array $rgb): float { [$r, $g, $b] = array_map(fn (float $c): float => max(0.0, min(1.0, $c)), $rgb); return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b; } function severityContrast(float $first, float $second): float { return (max($first, $second) + 0.05) / (min($first, $second) + 0.05); } /** OKLab Euclidean distance times 100. */ function severityDeltaE(array $first, array $second): float { $a = severityLinearToOklab($first); $b = severityLinearToOklab($second); return 100 * sqrt(($a[0] - $b[0]) ** 2 + ($a[1] - $b[1]) ** 2 + ($a[2] - $b[2]) ** 2); } /** * Value of a custom property in the first CSS block that starts with * $blockStart and defines it (app.css has more than one `.dark` block). */ function severityToken(string $css, string $blockStart, string $name): string { $offset = 0; while (($start = strpos($css, "\n{$blockStart} {", $offset)) !== false) { $end = strpos($css, "\n}", $start); $block = substr($css, $start, $end - $start); if (preg_match('/^\s*'.preg_quote($name, '/').':\s*([^;]+);/m', $block, $m) === 1) { return $m[1]; } $offset = $end; } throw new RuntimeException("{$name} not defined in {$blockStart}"); } /** * Surface the badge paints over: white at 7% over a dark page surface, * blended in sRGB like the browser does. * * @param array{0: float, 1: float, 2: float} $surface linear sRGB * @return array{0: float, 1: float, 2: float} */ function severityDarkBadgeBackground(array $surface): array { return array_map( fn (float $channel): float => severityDecode(0.93 * severityEncode($channel) + 0.07), $surface, ); } beforeEach(function () { $this->css = file_get_contents(__DIR__.'/../../../resources/css/app.css'); $this->light = fn (string $name): array => severityParseColor(severityToken($this->css, '@theme', $name)); $this->dark = fn (string $name): array => severityParseColor(severityToken($this->css, '.dark', $name)); // The dark page surfaces live in @theme; the page is dark by default. $this->darkSurface = fn (string $name): array => severityParseColor(severityToken($this->css, '@theme', $name)); }); dataset('severity tokens', ['--color-severity-high', '--color-severity-medium']); it('keeps the light severity dot at 3:1 or more on the badge background', function (string $token) { $color = ($this->light)($token); // x-status-badge: bg-neutral-100 is oklch(97% 0 0). White cards are lighter. $badge = severityParseColor('oklch(97% 0 0)'); expect(severityInGamut($color))->toBeTrue() ->and(severityContrast(severityLuminance($color), severityLuminance($badge)))->toBeGreaterThanOrEqual(3.0); })->with('severity tokens'); it('keeps the dark severity dot at 3:1 or more on the badge over every dark surface', function (string $token) { $color = ($this->dark)($token); expect(severityInGamut($color))->toBeTrue(); foreach (['--color-app', '--color-panel', '--color-surface', '--color-raised', '--color-selected'] as $surfaceToken) { $badge = severityDarkBadgeBackground(($this->darkSurface)($surfaceToken)); expect(severityContrast(severityLuminance($color), severityLuminance($badge))) ->toBeGreaterThanOrEqual(3.0, "{$token} on {$surfaceToken}"); } })->with('severity tokens'); it('keeps the critical dot (error) at 3:1 or more in both themes', function () { $lightBadge = severityParseColor('oklch(97% 0 0)'); $lightError = severityParseColor(severityToken($this->css, 'html:not(.dark)', '--color-error')); expect(severityContrast(severityLuminance($lightError), severityLuminance($lightBadge)))->toBeGreaterThanOrEqual(3.0); foreach (['--color-app', '--color-panel', '--color-surface', '--color-raised', '--color-selected'] as $surfaceToken) { $badge = severityDarkBadgeBackground(($this->darkSurface)($surfaceToken)); expect(severityContrast(severityLuminance(($this->dark)('--color-error')), severityLuminance($badge))) ->toBeGreaterThanOrEqual(3.0, "error on {$surfaceToken}"); } }); it('keeps medium visibly different from the accent and the warning color', function () { $css = $this->css; $theme = fn (string $name): array => severityParseColor(severityToken($css, '@theme', $name)); $lightWarning = severityParseColor(severityToken($css, 'html:not(.dark)', '--color-warning')); $lightMedium = $theme('--color-severity-medium'); $darkMedium = ($this->dark)('--color-severity-medium'); // Light: accent purple and the darkened warning. Dark: the brand accent // (--color-accent is --color-brand), its light tint, and the warning yellow. $lightAgainst = [ 'light accent' => severityParseColor(severityToken($css, '@theme', '--color-accent')), 'light warning' => $lightWarning, ]; $darkAgainst = [ 'dark accent (brand)' => $theme('--color-brand'), 'dark accent text (brand-light)' => $theme('--color-brand-light'), 'dark warning' => $theme('--color-warning'), ]; foreach ($lightAgainst as $name => $other) { expect(severityDeltaE($lightMedium, $other))->toBeGreaterThan(10.0, "light medium vs {$name}"); } foreach ($darkAgainst as $name => $other) { expect(severityDeltaE($darkMedium, $other))->toBeGreaterThan(10.0, "dark medium vs {$name}"); } // Warning is yellow in dark and brown in light, so check each against both. expect(severityDeltaE($lightMedium, $theme('--color-warning')))->toBeGreaterThan(10.0) ->and(severityDeltaE($darkMedium, $lightWarning))->toBeGreaterThan(10.0); }); it('keeps high distinct from medium and from the warning color', function () { $css = $this->css; $theme = fn (string $name): array => severityParseColor(severityToken($css, '@theme', $name)); expect(severityDeltaE($theme('--color-severity-high'), $theme('--color-severity-medium')))->toBeGreaterThan(10.0) ->and(severityDeltaE(($this->dark)('--color-severity-high'), ($this->dark)('--color-severity-medium')))->toBeGreaterThan(10.0); });