From 6bb8fcd5c7a940e80808dd0191e6dc9df3d250b7 Mon Sep 17 00:00:00 2001 From: Aditya Tripathi Date: Mon, 7 Sep 2026 12:11:59 +0000 Subject: [PATCH] feat(theme): extract shared Alpine theme controls into components Introduce theme-controls and theme-controls/picker Blade components housing the theme/page-width/custom-color logic previously duplicated in top-user-menu and profile/appearance, and fix custom theme surface colors to use oklch mixes instead of raw white/black/hex for correct contrast. --- resources/css/app.css | 133 +++++++++++-- .../views/components/theme-controls.blade.php | 185 ++++++++++++++++++ .../theme-controls/picker.blade.php | 48 +++++ .../views/components/top-user-menu.blade.php | 108 +--------- resources/views/layouts/app.blade.php | 8 +- resources/views/layouts/base.blade.php | 95 ++++++++- .../livewire/profile/appearance.blade.php | 154 +-------------- tests/Feature/AppearanceThemeTest.php | 54 ++--- tests/Feature/CustomThemeContrastTest.php | 111 +++++++++++ .../Feature/RealtimeTerminalPackagingTest.php | 4 +- tests/Feature/TerminalPageHeaderTest.php | 8 +- 11 files changed, 593 insertions(+), 315 deletions(-) create mode 100644 resources/views/components/theme-controls.blade.php create mode 100644 resources/views/components/theme-controls/picker.blade.php create mode 100644 tests/Feature/CustomThemeContrastTest.php diff --git a/resources/css/app.css b/resources/css/app.css index e246e4a05..c12b927e4 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1141,46 +1141,95 @@ html:not(.dark) { --theme-base-color is enough to generate a complete dark surface ladder. */ html[data-theme="custom"] { --theme-base-color: oklch(49.65% 0.2709 289.33); - --theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, white); + --theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, oklch(100% 0 0)); --theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground)); - --theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, #52525b); - --theme-placeholder-color: color-mix(in srgb, white 20%, var(--theme-base-color)); + --theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, oklch(44.19% 0.0146 285.79)); + /* Accent text/icons sit directly on dark surfaces. Force the lightness into a + readable band (and tame chroma) so any picked hue keeps enough contrast. */ + --theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.72, l, 0.86) min(c, 0.13) h); + /* Placeholders read as a light, near-neutral hint of the hue - never the raw + (possibly dark) base color, which is invisible on the dark input. */ + --theme-placeholder-color: oklch(from var(--theme-base-color) 0.62 min(c, 0.03) h); --color-accent: var(--theme-bright-color); --color-coollabs: var(--theme-bright-color); - --color-coollabs-100: color-mix(in oklab, var(--theme-bright-color) 88%, white); - --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, black); - --color-coollabs-300: color-mix(in oklab, var(--theme-bright-color) 72%, black); + --color-coollabs-100: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(100% 0 0)); + --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(0% 0 0)); + --color-coollabs-300: color-mix(in oklab, var(--theme-bright-color) 72%, oklch(0% 0 0)); /* Legacy dark-theme accent utilities use warning as the brand color. */ --color-warning: var(--theme-bright-color); --color-accent-foreground: var(--theme-accent-foreground); - --color-app: color-mix(in oklab, var(--theme-base-color) 12%, #09090a); - --color-panel: color-mix(in oklab, var(--theme-base-color) 14%, #0c0c0d); - --color-surface: color-mix(in oklab, var(--theme-base-color) 18%, #101011); - --color-raised: color-mix(in oklab, var(--theme-base-color) 24%, #141416); - --color-selected: color-mix(in oklab, var(--theme-base-color) 32%, #18181a); + --color-app: color-mix(in oklab, var(--theme-base-color) 12%, oklch(14.03% 0.0022 286.11)); + --color-panel: color-mix(in oklab, var(--theme-base-color) 14%, oklch(15.48% 0.0021 286.15)); + --color-surface: color-mix(in oklab, var(--theme-base-color) 18%, oklch(17.35% 0.0020 286.18)); + --color-raised: color-mix(in oklab, var(--theme-base-color) 24%, oklch(19.21% 0.0040 286.02)); + --color-selected: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06)); --color-coolgray-100: var(--color-surface); --color-coolgray-200: var(--color-raised); --color-coolgray-300: var(--color-selected); - --color-coolgray-400: color-mix(in oklab, var(--theme-base-color) 28%, #1b1b1e); - --color-coolgray-500: color-mix(in oklab, var(--theme-base-color) 32%, #202024); + --color-coolgray-400: color-mix(in oklab, var(--theme-base-color) 28%, oklch(22.34% 0.0058 285.92)); + --color-coolgray-500: color-mix(in oklab, var(--theme-base-color) 32%, oklch(24.52% 0.0075 285.83)); --coollabs-canvas: var(--color-app); --coollabs-elevated: var(--color-surface); --coollabs-recessed: var(--color-raised); - --coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, #111112); + --coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, oklch(17.81% 0.0020 286.19)); --color-content-surface: var(--coollabs-base); - --coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, #18181a); + --coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06)); --coollabs-line: var(--theme-border-color); --coollabs-hairline: color-mix(in srgb, var(--theme-border-color) 55%, var(--color-panel)); --color-nav-text: oklch(91.08% 0.0157 306.4); --color-nav-muted: oklch(87.24% 0.0188 306.63); --color-nav-active: oklch(100.0% 0 0); - --color-log: color-mix(in oklab, var(--theme-base-color) 20%, #080809); - --color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, #101011); + --color-log: color-mix(in oklab, var(--theme-base-color) 20%, oklch(13.49% 0.0024 286.07)); + --color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, oklch(17.35% 0.0020 286.18)); --color-fg-dim: oklch(91.08% 0.0157 306.4); --color-fg-faint: oklch(87.24% 0.0188 306.63); --coollabs-subtle: oklch(87.24% 0.0188 306.63); } +/* Light custom theme: the same single brand color, but surfaces tint toward + white and every foreground token flips to a dark, readable band. This selector + outranks the plain html:not(.dark) light palette, so it wins where they meet. + Accent fills (--theme-bright-color, --color-warning/coollabs) are inherited from + the block above so the JS-computed --theme-accent-foreground stays consistent. */ +html[data-theme="custom"]:not(.dark) { + --theme-scrollbar-thumb: color-mix(in srgb, var(--theme-base-color) 45%, oklch(60% 0 0)); + /* Accent text/icons on light surfaces: force lightness into a dark band. */ + --theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.30, l, 0.48) min(c, 0.16) h); + /* Placeholder: medium-dark, near-neutral hint of the hue. */ + --theme-placeholder-color: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + /* Surfaces hold a fixed high lightness (stays light, keeps dark text readable) + while injecting the base hue's chroma, so the tint is actually visible. + color-mix into white coupled lightness to the base and washed the tint out + (a 4% mix of any color into near-white is imperceptible). */ + --color-app: oklch(from var(--theme-base-color) 96.5% min(c, 0.035) h); + --color-panel: oklch(from var(--theme-base-color) 98.5% min(c, 0.025) h); + --color-surface: oklch(from var(--theme-base-color) 98% min(c, 0.03) h); + --color-raised: oklch(from var(--theme-base-color) 95.5% min(c, 0.04) h); + --color-selected: oklch(from var(--theme-base-color) 92% min(c, 0.05) h); + --color-coolgray-100: var(--color-surface); + --color-coolgray-200: var(--color-raised); + --color-coolgray-300: var(--color-selected); + --color-coolgray-400: oklch(from var(--theme-base-color) 90% min(c, 0.05) h); + --color-coolgray-500: oklch(from var(--theme-base-color) 87% min(c, 0.055) h); + --coollabs-canvas: var(--color-app); + --coollabs-elevated: oklch(from var(--theme-base-color) 98.5% min(c, 0.025) h); + --coollabs-recessed: var(--color-raised); + --coollabs-base: oklch(from var(--theme-base-color) 99.3% min(c, 0.018) h); + --color-content-surface: var(--coollabs-base); + --coollabs-fill: oklch(from var(--theme-base-color) 91% min(c, 0.05) h); + --coollabs-line: oklch(from var(--theme-base-color) 82% min(c, 0.055) h); + --coollabs-hairline: oklch(from var(--theme-base-color) 88% min(c, 0.04) h); + --color-nav-text: oklch(from var(--theme-base-color) 0.40 min(c, 0.04) h); + --color-nav-muted: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + --color-nav-active: oklch(from var(--theme-base-color) 0.22 min(c, 0.05) h); + --color-log: oklch(from var(--theme-base-color) 96.5% min(c, 0.035) h); + --color-log-toolbar: oklch(from var(--theme-base-color) 93% min(c, 0.045) h); + --color-fg: oklch(from var(--theme-base-color) 0.22 min(c, 0.04) h); + --color-fg-dim: oklch(from var(--theme-base-color) 0.40 min(c, 0.04) h); + --color-fg-faint: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); + --coollabs-subtle: oklch(from var(--theme-base-color) 0.50 min(c, 0.03) h); +} + html[data-theme="custom"] .control-selected, html[data-theme="custom"] .logs-viewer-btn-active { color: var(--color-accent-foreground); @@ -1237,7 +1286,55 @@ .dark [class~="dark:border-white/[0.06]"] { html[data-theme="custom"] input::placeholder, html[data-theme="custom"] textarea::placeholder { color: var(--theme-placeholder-color) !important; - opacity: 0.7; + /* The token is already a readable light tone; no extra opacity dampening, + which would drop it back below the contrast floor. */ + opacity: 1; +} + +/* Accent text/icons (links, badges, "update available" pill, check marks) use the + brand color as foreground on dark surfaces. Route them to the clamped, always + readable token; fills (bg-*) keep the brighter accent + its computed foreground. */ +html[data-theme="custom"] :is( + [class~="text-coollabs"], + [class~="text-warning"], + [class~="text-accent"], + [class~="dark:text-coollabs"], + [class~="dark:text-warning"] +) { + color: var(--theme-fg-on-surface) !important; +} + +html[data-theme="custom"] [class~="hover:text-coollabs"]:hover, +html[data-theme="custom"] [class~="hover:text-warning"]:hover, +html[data-theme="custom"] [class~="dark:hover:text-warning"]:hover, +html[data-theme="custom"] [class~="dark:hover:text-coollabs"]:hover { + color: var(--theme-fg-on-surface) !important; +} + +/* Light custom theme reaches raw neutral utilities too. Dark mode tints + everything through the pervasive dark:bg-*/dark:border-* utilities (which + resolve to --color-* vars the block overrides); light mode has no equivalent, + so components fall back to hardcoded neutrals. Remap the dominant light + surface/border utilities to the tinted custom vars so the color reaches the + whole UI, not just design-system classes. */ +html[data-theme="custom"]:not(.dark) :is([class~="bg-white"], [class~="bg-white/95"], [class~="bg-white/80"]) { + background-color: var(--coollabs-elevated) !important; +} + +html[data-theme="custom"]:not(.dark) [class~="bg-neutral-50"] { + background-color: var(--coollabs-canvas) !important; +} + +html[data-theme="custom"]:not(.dark) :is([class~="bg-neutral-100"], [class~="bg-neutral-200"]) { + background-color: var(--coollabs-fill) !important; +} + +html[data-theme="custom"]:not(.dark) [class~="border-neutral-200"] { + border-color: var(--coollabs-hairline) !important; +} + +html[data-theme="custom"]:not(.dark) :is([class~="border-neutral-300"], [class~="border-neutral-100"]) { + border-color: var(--coollabs-line) !important; } html[data-theme="custom"] input:read-only, diff --git a/resources/views/components/theme-controls.blade.php b/resources/views/components/theme-controls.blade.php new file mode 100644 index 000000000..795aefaa2 --- /dev/null +++ b/resources/views/components/theme-controls.blade.php @@ -0,0 +1,185 @@ +@props(['variant' => 'full']) + +@php($checkPath = 'm2.5 6.25 2.1 2.1 4.9-5') + +@if ($variant === 'menu') + {{-- Compact list for the profile dropdown. The parent controls visibility; + all theme state/logic comes from the shared window.themeControls(). --}} +
+ @foreach ([ + ['value' => 'light', 'label' => 'Light'], + ['value' => 'system', 'label' => 'System'], + ['value' => 'dark', 'label' => 'Dark'], + ['value' => 'custom', 'label' => 'Custom'], + ] as $option) + @if ($option['value'] === 'custom') +
+ + +
+ @else + + @endif + @endforeach +
+
+ Page width +
+ @foreach ([ + ['value' => 'full', 'label' => 'Full width'], + ['value' => 'centered', 'label' => 'Centered'], + ] as $option) + + @endforeach +
+@else + {{-- Full card grid for the Appearance settings page. --}} +
+
+
+
+

Color theme

+

Choose the color scheme used in this browser.

+
+
+
+ @foreach ([ + ['value' => 'light', 'label' => 'Light', 'description' => 'Bright surfaces and dark text.', 'preview' => 'bg-white'], + ['value' => 'system', 'label' => 'System', 'description' => 'Follow your operating system.', 'preview' => 'bg-gradient-to-r from-white via-neutral-400 to-[#050505]'], + ['value' => 'dark', 'label' => 'Dark', 'description' => 'Dark surfaces and soft contrast.', 'preview' => 'bg-[#181818]'], + ['value' => 'custom', 'label' => 'Custom', 'description' => 'Tint light or dark surfaces with any color.', 'preview' => ''], + ] as $option) + @if ($option['value'] === 'custom') + {{-- Clicking the card opens the custom color picker popover (color + light/dark). --}} +
+
+
+
+
+
+
+
+
+
+
+ {{ $option['label'] }} + +
+

+ {{ $option['description'] }} +

+
+
+ +
+ @else +
+
+
+
+
+
+
+
+
+ + {{ $option['label'] }} + + +
+

+ {{ $option['description'] }} +

+
+
+ @endif + @endforeach +
+
+ +
+
+
+

Page width

+

Choose how content uses the available browser width.

+
+
+
+ @foreach ([ + ['value' => 'full', 'label' => 'Full width', 'description' => 'Use all available space for page content.'], + ['value' => 'centered', 'label' => 'Centered', 'description' => 'Keep content centered at a comfortable maximum width.'], + ] as $option) + + @endforeach +
+
+
+@endif diff --git a/resources/views/components/theme-controls/picker.blade.php b/resources/views/components/theme-controls/picker.blade.php new file mode 100644 index 000000000..f4b1ede29 --- /dev/null +++ b/resources/views/components/theme-controls/picker.blade.php @@ -0,0 +1,48 @@ +@props(['position' => 'left-0 top-full mt-2']) + +{{-- Custom color picker popover: color field + light/dark mode in one panel. + Renders inline (no own Alpine scope) so it uses the parent themeControls() + state: pickerOpen, themeColor, customMode, previewThemeColor, saveThemeColor, + setCustomMode. --}} + diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index 537783d0f..ad5f0e6f1 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -11,10 +11,6 @@
$sidebar]) x-data="{ open: false, appearanceOpen: false, - theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), - pageWidth: localStorage.getItem('pageWidth') || 'full', - themeColor: localStorage.getItem('themeColor') || '#6b16ed', - themeColorFrame: null, avatarUrl: @js($user?->avatar_path ? profile_avatar_url($user) : null), openPanel() { this.appearanceOpen = false; @@ -23,51 +19,6 @@ closePanel() { this.open = false; }, - setTheme(type, closeMenu = true) { - this.theme = type; - localStorage.setItem('theme', type); - - if (closeMenu) { - this.closePanel(); - } - - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - const isDark = type === 'dark' || type === 'custom' || (type === 'system' && prefersDark); - document.documentElement.classList.toggle('dark', isDark); - document.documentElement.dataset.theme = type === 'custom' ? 'custom' : (isDark ? 'dark' : 'light'); - document.documentElement.style.setProperty('--theme-base-color', localStorage.themeColor || '#6b16ed'); - document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor)); - document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff'); - }, - setWidth(width) { - this.pageWidth = width; - localStorage.setItem('pageWidth', width); - window.dispatchEvent(new CustomEvent('page-width-changed', { detail: width })); - }, - previewThemeColor(color) { - this.themeColor = color; - - if (this.theme !== 'custom') { - this.theme = 'custom'; - document.documentElement.classList.add('dark'); - document.documentElement.dataset.theme = 'custom'; - } - - if (this.themeColorFrame) { - return; - } - - this.themeColorFrame = requestAnimationFrame(() => { - document.documentElement.style.setProperty('--theme-base-color', this.themeColor); - document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor)); - this.themeColorFrame = null; - }); - }, - saveThemeColor(color) { - this.previewThemeColor(color); - localStorage.setItem('themeColor', color); - localStorage.setItem('theme', 'custom'); - }, }" @avatar-updated.window="avatarUrl = $event.detail.url" @keydown.escape.window="closePanel()" @click.outside="closePanel()"> -
- @foreach ([ - ['value' => 'light', 'label' => 'Light'], - ['value' => 'system', 'label' => 'System'], - ['value' => 'dark', 'label' => 'Dark'], - ['value' => 'custom', 'label' => 'Custom'], - ] as $option) - @if ($option['value'] === 'custom') -
- - - Custom - - - -
- @else - - @endif - @endforeach -
-
- Page width -
- @foreach ([ - ['value' => 'full', 'label' => 'Full width'], - ['value' => 'centered', 'label' => 'Centered'], - ] as $option) - - @endforeach +
+
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 8cccc424c..aa1311d1e 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -70,10 +70,9 @@ class="flex h-full items-center gap-0.5 min-w-0 flex-1 border-b border-neutral-2 {{-- ============ MOBILE SLIDE-OVER SIDEBAR (shadcn-style sheet) ============ --}} -