NavGradientDirection::class, ]; /** Stock values — must match the migration defaults and the @theme tokens in app.css. */ public const DEFAULTS = [ 'primary_color' => '#2563eb', 'secondary_color' => '#3b82f6', 'tertiary_color' => '#1d4ed8', 'nav_gradient_from' => '#ffffff', 'nav_gradient_mid' => null, 'nav_gradient_to' => null, 'nav_gradient_direction' => 'none', ]; protected static function booted(): void { static::saved(fn () => Cache::forget('site-theme')); static::deleted(fn () => Cache::forget('site-theme')); } /** The single site-wide theme row, cached until the next save. */ public static function current(): self { // refresh() hydrates the DB column defaults into the fresh model. return Cache::rememberForever( 'site-theme', fn () => static::query()->first() ?? static::query()->create([])->refresh() ); } /** True when the theme matches the stock look exactly (no override needed). */ public function isDefault(): bool { if ($this->logo_path !== null) { return false; } foreach (self::DEFAULTS as $key => $default) { $value = $this->{$key}; if ($value instanceof \BackedEnum) { $value = $value->value; } if ($value === null || $default === null) { if ($value !== $default) { return false; } continue; } if (strtolower((string) $value) !== strtolower((string) $default)) { return false; } } return true; } public function primaryHover(): string { return self::darken($this->primary_color, 0.15); } /** CSS background value for the top bar: solid color or gradient. */ public function navBackgroundCss(): string { $stops = $this->activeGradientStops(); if ($this->nav_gradient_direction === NavGradientDirection::None || count($stops) < 2) { return $this->nav_gradient_from; } $list = implode(', ', $stops); return $this->nav_gradient_direction === NavGradientDirection::Radial ? "radial-gradient(ellipse at top, {$list})" : "linear-gradient({$this->nav_gradient_direction->cssKeyword()}, {$list})"; } /** Whether the nav background is dark enough to need white text (WCAG contrast). */ public function navIsDark(): bool { $stops = $this->nav_gradient_direction === NavGradientDirection::None ? [$this->nav_gradient_from] : $this->activeGradientStops(); $l = array_sum(array_map(self::luminance(...), $stops)) / count($stops); // White text wins when its contrast beats gray-900's (luminance ~0.0106). return (1.05 / ($l + 0.05)) >= (($l + 0.05) / 0.0606); } public function navLinkColor(): string { return $this->navIsDark() ? '#ffffff' : $this->primary_color; } public function navTextColor(): string { return $this->navIsDark() ? '#ffffff' : '#111827'; } public function navMutedColor(): string { return $this->navIsDark() ? 'rgba(255, 255, 255, 0.75)' : '#6b7280'; } /** @return list */ private function activeGradientStops(): array { return array_values(array_filter([ $this->nav_gradient_from, $this->nav_gradient_mid, $this->nav_gradient_to, ])); } /** WCAG relative luminance of a #rrggbb color. */ private static function luminance(string $hex): float { [$r, $g, $b] = sscanf($hex, '#%02x%02x%02x'); $lin = function (int $c): float { $c = $c / 255; return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4; }; return 0.2126 * $lin($r) + 0.7152 * $lin($g) + 0.0722 * $lin($b); } private static function darken(string $hex, float $amount): string { [$r, $g, $b] = sscanf($hex, '#%02x%02x%02x'); return sprintf( '#%02x%02x%02x', (int) round($r * (1 - $amount)), (int) round($g * (1 - $amount)), (int) round($b * (1 - $amount)) ); } }