feat(ui): add custom theme color and polish light-mode theming

Support a custom accent theme across nav, terminal, scrollbars, and loaders.
Improve light-mode tokens, helper popup placement, and theme persistence
(including purple→custom migration). Cover with appearance and scrollbar tests.
This commit is contained in:
Andras Bacsai
2026-08-06 16:59:41 +02:00
parent fae6e15795
commit 6b41899d5b
14 changed files with 476 additions and 44 deletions
+165 -2
View File
@@ -56,7 +56,12 @@
--color-fg-dim: #b4b4b8;
--color-fg-faint: #6e6e74;
--color-accent: #6b16ed;
--color-accent-foreground: #ffffff;
--color-hairline: rgba(255, 255, 255, 0.08);
--color-nav-text: #525252;
--color-nav-muted: #666666;
--color-nav-active: #171717;
--color-log: #0d0d0d;
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.4);
}
@@ -106,6 +111,14 @@
@layer base {
html {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
*,
::after,
::before,
@@ -372,6 +385,26 @@ tr td:first-child {
border-left-color: var(--color-warning) !important;
}
html[data-theme="custom"] .loading-indicator,
html[data-theme="custom"] .animate-spin {
color: var(--theme-bright-color) !important;
}
html[data-theme="custom"] #nprogress .bar {
background: var(--theme-bright-color) !important;
}
html[data-theme="custom"] #nprogress .peg {
box-shadow:
0 0 10px var(--theme-bright-color),
0 0 5px var(--theme-bright-color) !important;
}
html[data-theme="custom"] #nprogress .spinner-icon {
border-top-color: var(--theme-bright-color) !important;
border-left-color: var(--theme-bright-color) !important;
}
.log-highlight {
background-color: rgba(234, 179, 8, 0.4);
border-radius: 2px;
@@ -482,6 +515,12 @@ html.dark .terminal-fullscreen-shell[data-console-theme="system"] {
--console-theme-border: rgb(255 255 255 / 0.08);
}
html[data-theme="custom"] .application-console-shell[data-console-theme="system"],
html[data-theme="custom"] .terminal-fullscreen-shell[data-console-theme="system"] {
--console-theme-background: var(--color-log);
--console-theme-border: var(--coollabs-line);
}
.console-theme-selector {
scrollbar-color: rgb(161 161 170) transparent;
scrollbar-width: thin;
@@ -741,6 +780,120 @@ body.terminal-is-fullscreen .terminal-fullscreen-shell [data-terminal-mobile-too
--coollabs-line: oklch(32% 0 0);
--coollabs-hairline: oklch(26.9% 0 0);
--coollabs-subtle: oklch(70.8% 0 0);
--color-nav-text: #a8a8b0;
--color-nav-muted: #7a7a84;
--color-nav-active: #f2f2f2;
}
/* Theme surfaces are derived from one brand color. Changing
--theme-base-color is enough to generate a complete dark surface ladder. */
html[data-theme="custom"] {
--theme-base-color: #6b16ed;
--theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, white);
--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 82%, var(--theme-base-color));
--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);
/* 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-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);
--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);
--color-content-surface: var(--coollabs-base);
--coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, #18181a);
--coollabs-line: var(--theme-border-color);
--coollabs-hairline: color-mix(in srgb, var(--theme-border-color) 55%, var(--color-panel));
--color-nav-text: #e4dfea;
--color-nav-muted: #d8d2df;
--color-nav-active: #ffffff;
--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-fg-dim: #e4dfea;
--color-fg-faint: #d8d2df;
--coollabs-subtle: #d8d2df;
}
html[data-theme="custom"] .control-selected,
html[data-theme="custom"] .logs-viewer-btn-active,
html[data-theme="custom"] .button-highlighted:hover {
color: var(--color-accent-foreground);
}
html[data-theme="custom"] * {
scrollbar-color: var(--theme-scrollbar-thumb) var(--color-panel);
}
html[data-theme="custom"] *::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: var(--theme-scrollbar-thumb);
}
html[data-theme="custom"] *::-webkit-scrollbar-thumb:hover {
background: var(--theme-bright-color);
}
html[data-theme="custom"] *::-webkit-scrollbar-track {
background: var(--color-panel);
}
html[data-theme="custom"] .application-settings-navigation {
overflow-y: scroll;
scrollbar-gutter: stable;
scrollbar-width: thin;
}
html[data-theme="custom"] .application-settings-navigation::-webkit-scrollbar {
width: 8px;
}
/* Use the same surface recipe as application settings cards for legacy
translucent cards and tables across the application. */
html[data-theme="custom"] .data-table,
html[data-theme="custom"] .box,
html[data-theme="custom"] .coolbox,
html[data-theme="custom"] .resource-view,
html[data-theme="custom"] [class~="bg-surface"],
html[data-theme="custom"] [class~="dark:bg-surface"],
html[data-theme="custom"] [class~="dark:bg-white/[0.025]"] {
background-color: var(--color-content-surface) !important;
}
.dark [class~="dark:border-white/[0.07]"],
.dark [class~="dark:border-white/[0.08]"] {
border-color: var(--coollabs-line) !important;
}
.dark [class~="dark:border-white/[0.06]"] {
border-color: var(--coollabs-hairline) !important;
}
html[data-theme="custom"] input::placeholder,
html[data-theme="custom"] textarea::placeholder {
color: var(--theme-placeholder-color) !important;
opacity: 1;
}
html[data-theme="custom"] input:read-only,
html[data-theme="custom"] textarea:read-only,
html[data-theme="custom"] input:disabled,
html[data-theme="custom"] textarea:disabled {
color: var(--color-fg-dim) !important;
}
/* Public authentication surfaces */
@@ -1680,7 +1833,8 @@ input[type="search"]::-webkit-search-results-decoration {
/* Empty states inside cards keep the same inset as other card content. */
.application-settings-section-body:has(> .empty-state:only-child),
.application-settings-section-body.is-flush:has(> .empty-state:only-child) {
.application-settings-section-body.is-flush:has(> .empty-state:only-child),
.application-settings-section-body.is-flush > div:has(> .empty-state:only-child) {
padding: 1rem;
}
@@ -2480,6 +2634,15 @@ input[type="search"]::-webkit-search-results-decoration {
background: rgba(255, 255, 255, 0.035);
}
html[data-theme="custom"] .logs-viewer-toolbar {
border-bottom-color: var(--coollabs-line);
background: var(--color-log-toolbar);
}
html[data-theme="custom"] .logs-viewer-timestamp {
color: var(--color-fg-faint);
}
.logs-viewer-toolbar-controls {
display: flex;
min-width: 0;
@@ -2947,7 +3110,7 @@ input[type="search"]::-webkit-search-results-decoration {
}
.dark .runtime-log-viewport {
background: oklch(11.5% 0 0);
background: var(--color-log);
color: var(--color-fg-dim);
}
+6 -6
View File
@@ -223,7 +223,7 @@
}
@utility menu-item {
@apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg;
@apply relative flex gap-2.5 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-md truncate min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05];
}
@utility menu-item-icon {
@apply shrink-0 size-[18px] opacity-90;
@@ -235,21 +235,21 @@
@utility menu-item-active {
/* Solid selected pill only — no accent rail / border. */
@apply overflow-hidden rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06];
@apply overflow-hidden rounded-md bg-black/[0.05] text-nav-active hover:bg-black/[0.05] dark:bg-white/[0.06] dark:hover:bg-white/[0.06];
}
/* Subtle Title-Case section header */
@utility nav-section {
@apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-neutral-400 dark:text-fg-faint/80 select-none;
@apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none;
}
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
/* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-neutral-500 dark:text-fg-faint hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.05] dark:hover:text-fg;
@apply relative flex gap-2.5 items-center h-8 pl-3 pr-2.5 w-full text-[13px] font-medium rounded-md min-w-0 transition-colors text-nav-text hover:bg-neutral-100 hover:text-nav-active dark:hover:bg-white/[0.05];
}
@utility menu-subitem-active {
@apply rounded-md bg-black/[0.05] text-black hover:bg-black/[0.05] dark:bg-white/[0.06] dark:text-fg dark:hover:bg-white/[0.06];
@apply rounded-md bg-black/[0.05] text-nav-active hover:bg-black/[0.05] dark:bg-white/[0.06] dark:hover:bg-white/[0.06];
}
@utility sub-menu-wrapper {
@@ -257,7 +257,7 @@
}
@utility sub-menu-item {
@apply relative flex gap-2 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-none truncate min-w-0 transition-colors text-neutral-500 dark:text-fg-dim hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-black dark:hover:text-fg;
@apply relative flex gap-2 items-center h-8 px-2.5 w-full text-[13px] font-medium rounded-none truncate min-w-0 transition-colors text-nav-text hover:bg-neutral-100 dark:hover:bg-white/[0.05] hover:text-nav-active;
}
@utility sub-menu-item-icon {
+19 -1
View File
@@ -42,7 +42,25 @@ function createApplicationTerminalTheme(accent, colors = {}) {
};
}
function customThemeAccent() {
const color = localStorage.getItem('themeColor') || '#6b16ed';
if (!/^#[0-9a-f]{6}$/i.test(color)) {
return '#7c3aed';
}
const channels = color.match(/[a-f\d]{2}/gi).map((channel) => (
Math.round(parseInt(channel, 16) * 0.85 + 255 * 0.15)
));
return `#${channels.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
}
function createSystemTerminalTheme() {
if (document.documentElement.dataset.theme === 'custom') {
return createApplicationTerminalTheme(customThemeAccent());
}
if (document.documentElement.classList.contains('dark')) {
return createApplicationTerminalTheme('#8C8E9C');
}
@@ -193,7 +211,7 @@ export function initializeTerminalComponent() {
});
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme'],
attributeFilter: ['class', 'data-theme', 'style'],
});
// Recover if a previous portal build left the terminal on <body>.
+3 -3
View File
@@ -54,11 +54,11 @@
const triggerRect = trigger.getBoundingClientRect();
const popupRect = popup.getBoundingClientRect();
const padding = 8;
let top = triggerRect.bottom + padding;
let top = triggerRect.top - popupRect.height - padding;
let left = triggerRect.right - popupRect.width;
if (top + popupRect.height > window.innerHeight - padding) {
top = triggerRect.top - popupRect.height - padding;
if (top < padding) {
top = triggerRect.bottom + padding;
}
left = Math.min(Math.max(padding, left), window.innerWidth - popupRect.width - padding);
+4 -2
View File
@@ -26,6 +26,7 @@
const userSettings = localStorage.getItem('theme');
if (userSettings !== 'system') { return; }
document.documentElement.classList.toggle('dark', e.matches);
document.documentElement.dataset.theme = e.matches ? 'dark' : 'light';
});
this.queryTheme();
},
@@ -34,7 +35,7 @@
const userSettings = localStorage.getItem('theme') || 'dark';
localStorage.setItem('theme', userSettings);
let isDark = false;
if (userSettings === 'dark') {
if (userSettings === 'dark' || userSettings === 'custom') {
document.documentElement.classList.add('dark');
isDark = true;
} else if (userSettings === 'light') {
@@ -45,7 +46,8 @@
} else {
document.documentElement.classList.remove('dark');
}
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#0d0d0d' : '#ffffff');
document.documentElement.dataset.theme = userSettings === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
}
}">
{{-- Search is only useful when workspace resources are available --}}
@@ -34,6 +34,20 @@
--sth-document: #a3e635;
}
html[data-theme="custom"] #server-timing-hud {
--sth-background: var(--color-surface);
--sth-text: var(--color-fg-dim);
--sth-strong: var(--color-fg);
--sth-secondary: var(--color-fg-dim);
--sth-muted: var(--color-fg-faint);
--sth-border: var(--coollabs-line);
--sth-surface: var(--color-raised);
--sth-scrollbar-track: var(--color-panel);
--sth-scrollbar-thumb: var(--color-selected);
--sth-livewire: var(--theme-bright-color);
--sth-xhr: var(--theme-bright-color);
}
#server-timing-hud [data-sth-log] {
scrollbar-color: var(--sth-scrollbar-thumb) var(--sth-scrollbar-track);
scrollbar-width: thin;
@@ -11,17 +11,24 @@
<div @class(['relative', 'min-w-0' => $sidebar]) x-data="{
open: false,
appearanceOpen: false,
theme: localStorage.getItem('theme') || 'dark',
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
setTheme(type) {
this.theme = type;
localStorage.setItem('theme', type);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = type === 'dark' || (type === 'system' && prefersDark);
const isDark = type === 'dark' || type === 'custom' || (type === 'system' && prefersDark);
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.dataset.theme = isDark ? 'dark' : 'light';
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');
},
setThemeColor() {
localStorage.setItem('themeColor', this.themeColor);
this.setTheme('custom');
},
}" @keydown.escape.window="open = false; appearanceOpen = false"
@click.outside="open = false; appearanceOpen = false">
<button type="button" @click="open = !open"
@@ -80,16 +87,37 @@
['value' => 'light', 'label' => 'Light'],
['value' => 'system', 'label' => 'System'],
['value' => 'dark', 'label' => 'Dark'],
['value' => 'custom', 'label' => 'Custom'],
] as $option)
<button type="button" @click="setTheme('{{ $option['value'] }}')"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span>{{ $option['label'] }}</span>
<svg x-show="theme === '{{ $option['value'] }}'" class="size-3.5 text-coollabs dark:text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@if ($option['value'] === 'custom')
<div
class="relative flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span class="flex items-center gap-2">
<span class="size-3.5 rounded-full border border-white/20"
:style="`background: ${themeColor}`"></span>
Custom
</span>
<svg x-show="theme === 'custom'" class="size-3.5 text-coollabs dark:text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
<input type="color" x-model="themeColor" @input="setThemeColor()"
aria-label="Custom theme color"
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
</div>
@else
<button type="button" @click="setTheme('{{ $option['value'] }}')"
class="flex h-8 w-full items-center justify-between rounded-md px-2 text-left text-xs text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-950 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg">
<span>{{ $option['label'] }}</span>
<svg x-show="theme === '{{ $option['value'] }}'"
class="size-3.5 text-coollabs dark:text-warning" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endif
@endforeach
</div>
+27 -7
View File
@@ -1,12 +1,32 @@
<!DOCTYPE html>
<html data-theme="dark" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<script>
<script data-navigate-once>
// Immediate theme application - runs before any rendering
(function () {
const t = localStorage.theme || 'dark';
const d = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList[d ? 'add' : 'remove']('dark');
document.documentElement.setAttribute('data-theme', d ? 'dark' : 'light');
window.themeAccentForeground = (color) => {
const channels = color.match(/[a-f\d]{2}/gi).map(channel => parseInt(channel, 16) * 0.85 + 255 * 0.15);
const luminance = channels
.map(channel => channel / 255)
.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
return luminance > 0.179 ? '#000000' : '#ffffff';
};
window.applyStoredTheme = () => {
const theme = localStorage.theme === 'purple' ? 'custom' : (localStorage.theme || 'dark');
const themeColor = localStorage.themeColor || '#6b16ed';
const isDark = theme === 'dark' || theme === 'custom' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
localStorage.theme = theme;
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.dataset.theme = theme === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.documentElement.style.setProperty('--theme-base-color', themeColor);
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
};
document.addEventListener('livewire:navigated', window.applyStoredTheme);
window.applyStoredTheme();
})();
</script>
@@ -54,7 +74,7 @@
<script>
// Update theme-color meta tag (non-critical, can run async)
const t = localStorage.theme || 'dark';
const isDark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
const isDark = t === 'dark' || t === 'custom' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.getElementById('theme-color-meta')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
</script>
<style>
@@ -156,7 +176,7 @@
if (theme == 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
if (theme == 'dark') {
if (theme == 'dark' || theme == 'custom') {
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#ffffff'
@@ -1,7 +1,8 @@
<div>
<x-slot:title>Appearance | Coolify</x-slot>
<div x-data="{
theme: localStorage.getItem('theme') || 'dark',
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
init() {
localStorage.setItem('theme', this.theme);
this.applyTheme();
@@ -11,10 +12,17 @@
localStorage.setItem('theme', type);
this.applyTheme();
},
setThemeColor() {
localStorage.setItem('themeColor', this.themeColor);
this.setTheme('custom');
},
applyTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = this.theme === 'dark' || (this.theme === 'system' && prefersDark);
const isDark = this.theme === 'dark' || this.theme === 'custom' || (this.theme === 'system' && prefersDark);
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.dataset.theme = this.theme === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.documentElement.style.setProperty('--theme-base-color', this.themeColor);
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
},
}" class="mt-8 flex w-full max-w-[1180px] flex-col gap-6 lg:mt-3">
@@ -25,22 +33,34 @@
<p>Choose the color scheme used in this browser.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-3 sm:grid-cols-3">
<div class="application-settings-section-body grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@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' => 'Choose any color for dark surfaces.', 'preview' => ''],
] as $option)
<button type="button" @click="setTheme('{{ $option['value'] }}')"
class="group overflow-hidden rounded-[10px] border border-neutral-200 bg-white text-left transition-[border-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-white/[0.025] dark:hover:border-white/[0.12]"
<div role="button" tabindex="0"
@if ($option['value'] !== 'custom')
@click="setTheme('{{ $option['value'] }}')"
@keydown.enter.prevent="setTheme('{{ $option['value'] }}')"
@endif
class="group relative overflow-hidden rounded-[10px] border border-neutral-200 bg-white text-left transition-[border-color,box-shadow] hover:border-neutral-300 hover:shadow-sm dark:border-white/[0.07] dark:bg-white/[0.025] dark:hover:border-white/[0.12]"
:class="theme === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="h-20 {{ $option['preview'] }} border-b border-neutral-200 dark:border-white/[0.07]">
<div class="h-20 {{ $option['preview'] }} border-b border-neutral-200 dark:border-white/[0.07]"
@if ($option['value'] === 'custom') :style="`background: color-mix(in oklab, ${themeColor} 28%, #101011)`" @endif>
<div class="flex h-full items-center justify-center">
<div
@if ($option['value'] === 'custom')
<div class="h-10 w-20 rounded-md border border-white/15 p-1 shadow-sm">
<div class="h-full w-full rounded-sm" :style="`background: ${themeColor}`"></div>
</div>
@else
<div
class="h-8 w-20 rounded-md border border-black/10 bg-neutral-100/80 shadow-sm dark:border-white/10 dark:bg-black/20">
</div>
</div>
@endif
</div>
</div>
<div class="p-3">
@@ -55,7 +75,12 @@
{{ $option['description'] }}
</p>
</div>
</button>
@if ($option['value'] === 'custom')
<input type="color" x-model="themeColor" @input="setThemeColor()"
aria-label="Custom theme color"
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
@endif
</div>
@endforeach
</div>
</section>
@@ -274,8 +274,8 @@
}" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full flex-col overflow-hidden xl:h-auto xl:min-h-0 xl:flex-1">
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-2 flex flex-1 min-h-0 flex-col overflow-hidden lg:mt-0'">
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
class="logs-viewer flex min-h-0 w-full flex-col overflow-hidden bg-white text-neutral-800 dark:bg-[#0d0d0d] dark:text-neutral-100"
:class="fullscreen ? 'h-full' : 'flex-1 rounded-xl border border-neutral-200 shadow-sm dark:border-neutral-800'">
class="logs-viewer flex min-h-0 w-full flex-col overflow-hidden bg-white text-neutral-800 dark:bg-log dark:text-neutral-100"
:class="fullscreen ? 'h-full' : 'flex-1 rounded-xl border border-neutral-200 shadow-sm dark:border-coolgray-200'">
<div class="logs-viewer-toolbar">
@php
$deploymentStatus = data_get($application_deployment_queue, 'status');
+137
View File
@@ -0,0 +1,137 @@
<?php
test('custom color theme is available and applied across theme controls', function () {
$appearance = file_get_contents(resource_path('views/livewire/profile/appearance.blade.php'));
$accountMenu = file_get_contents(resource_path('views/components/top-user-menu.blade.php'));
$layout = file_get_contents(resource_path('views/layouts/base.blade.php'));
$deploymentLogs = file_get_contents(resource_path('views/livewire/project/application/deployment/show.blade.php'));
$serverTiming = file_get_contents(resource_path('views/components/server-timing-hud.blade.php'));
$terminal = file_get_contents(resource_path('js/terminal.js'));
$styles = file_get_contents(resource_path('css/app.css'));
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($appearance)
->toContain("['value' => 'custom', 'label' => 'Custom'")
->toContain('type="color"')
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->toContain("localStorage.setItem('themeColor', this.themeColor)")
->toContain("this.theme === 'custom'")
->and($accountMenu)
->toContain("['value' => 'custom', 'label' => 'Custom']")
->toContain('aria-label="Custom theme color"')
->toContain("localStorage.setItem('themeColor', this.themeColor)")
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->and($layout)
->toContain("t === 'custom'")
->toContain("localStorage.themeColor || '#6b16ed'")
->toContain('--theme-accent-foreground')
->toContain('window.applyStoredTheme')
->toContain("document.addEventListener('livewire:navigated', window.applyStoredTheme)")
->and($styles)
->toContain('html[data-theme="custom"]')
->toContain('--theme-base-color: #6b16ed;')
->toContain('--theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, white);')
->toContain('--color-accent: var(--theme-bright-color);')
->toContain('--color-coollabs: var(--theme-bright-color);')
->toContain('--color-warning: var(--theme-bright-color);')
->toContain('--color-accent-foreground: var(--theme-accent-foreground);')
->toContain('--color-fg-dim: #e4dfea;')
->toContain('--color-fg-faint: #d8d2df;')
->toContain('html[data-theme="custom"] .control-selected')
->toContain('--theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground));')
->toContain('--theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, #52525b);')
->toContain('--theme-placeholder-color: color-mix(in srgb, white 82%, var(--theme-base-color));')
->toContain('html[data-theme="custom"] *')
->toContain('scrollbar-color: var(--theme-scrollbar-thumb) var(--color-panel);')
->toContain('html[data-theme="custom"] *::-webkit-scrollbar-thumb')
->toContain('html[data-theme="custom"] .application-settings-navigation')
->toContain('scrollbar-gutter: stable;')
->toContain('--color-panel: color-mix(in oklab, var(--theme-base-color) 14%, #0c0c0d);')
->toContain('--color-surface: color-mix(in oklab, var(--theme-base-color) 18%, #101011);')
->toContain('--coollabs-elevated: var(--color-surface);')
->toContain('--coollabs-recessed: var(--color-raised);')
->toContain('--coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, #111112);')
->toContain('--color-content-surface: var(--coollabs-base);')
->toContain('--coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, #18181a);')
->toContain('--color-nav-text: #e4dfea;')
->toContain('--color-nav-muted: #d8d2df;')
->toContain('--color-log: color-mix(in oklab, var(--theme-base-color) 20%, #080809);')
->toContain('--color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, #101011);')
->toContain('html[data-theme="custom"] .logs-viewer-toolbar')
->toContain('html[data-theme="custom"] .logs-viewer-timestamp')
->toContain('html[data-theme="custom"] #nprogress .bar')
->toContain('background: var(--theme-bright-color) !important;')
->toContain('html[data-theme="custom"] #nprogress .spinner-icon')
->toContain('html[data-theme="custom"] .loading-indicator')
->toContain('html[data-theme="custom"] .data-table')
->toContain('html[data-theme="custom"] [class~="dark:bg-white/[0.025]"]')
->toContain('html[data-theme="custom"] [class~="dark:bg-surface"]')
->toContain('background-color: var(--color-content-surface) !important;')
->toContain('.dark [class~="dark:border-white/[0.08]"]')
->toContain('.dark [class~="dark:border-white/[0.06]"]')
->toContain('border-color: var(--coollabs-hairline) !important;')
->toContain('html[data-theme="custom"] input::placeholder')
->toContain('color: var(--theme-placeholder-color) !important;')
->toContain('html[data-theme="custom"] input:read-only')
->and($deploymentLogs)
->toContain('dark:bg-log')
->not->toContain('dark:bg-[#0d0d0d]')
->and($serverTiming)
->toContain('html[data-theme="custom"] #server-timing-hud')
->toContain('--sth-background: var(--color-surface);')
->toContain('--sth-livewire: var(--theme-bright-color);')
->and($utilities)
->toContain('text-nav-text')
->toContain('text-nav-muted')
->toContain('text-nav-active')
->and($terminal)
->toContain("dataset.theme === 'custom'")
->toContain("localStorage.getItem('themeColor')")
->toContain("attributeFilter: ['class', 'data-theme', 'style']")
->and($styles)
->toContain('html[data-theme="custom"] .application-console-shell[data-console-theme="system"]')
->toContain('--console-theme-background: var(--color-log);')
->toContain('--console-theme-border: var(--coollabs-line);');
});
test('navigation colors meet WCAG AA contrast requirements', function () {
$styles = file_get_contents(resource_path('css/app.css'));
$contrastRatio = function (string $foreground, string $background): float {
$luminance = function (string $color): float {
$channels = array_map(
fn (string $channel): float => hexdec($channel) / 255,
str_split(ltrim($color, '#'), 2),
);
$channels = array_map(
fn (float $channel): float => $channel <= 0.04045
? $channel / 12.92
: (($channel + 0.055) / 1.055) ** 2.4,
$channels,
);
return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
};
[$lighter, $darker] = collect([$luminance($foreground), $luminance($background)])
->sortDesc()
->values()
->all();
return ($lighter + 0.05) / ($darker + 0.05);
};
$themes = [
[['#525252', '#666666', '#171717'], '#ffffff'],
[['#a8a8b0', '#7a7a84', '#f2f2f2'], '#0c0c0d'],
[['#e4dfea', '#d8d2df', '#ffffff', '#e4dfea', '#d8d2df'], '#58585a'],
];
foreach ($themes as [$colors, $background]) {
foreach ($colors as $color) {
expect($styles)->toContain($color);
expect($contrastRatio($color, $background))->toBeGreaterThanOrEqual(4.5);
}
}
});
@@ -36,6 +36,12 @@ it('renders empty states flush with no outer inset inside settings sections', fu
->toMatch('/\.application-settings-section-body\.is-flush \.empty-state[\s\S]*?margin:\s*0/');
});
it('insets empty states nested inside a flush settings section wrapper', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)->toContain('.application-settings-section-body.is-flush > div:has(> .empty-state:only-child)');
});
it('renders compact size without the full-page min height class', function () {
$html = $this->blade(
'<x-empty title="No cloud tokens" description="Add a provider token." icon-name="keys" size="sm" />'
+8
View File
@@ -59,6 +59,14 @@ test('helper popup stays in its alpine scope during livewire morphs', function (
->not->toContain('x-teleport');
});
test('helper popup prefers a position above its info button', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
expect($helper)
->toContain('let top = triggerRect.top - popupRect.height - padding;')
->toContain('top = triggerRect.bottom + padding;');
});
test('helper popup supports focus dismissal and tooltip aria relationships', function () {
$helper = file_get_contents(resource_path('views/components/helper.blade.php'));
+11
View File
@@ -0,0 +1,11 @@
<?php
test('native scrollbars follow the active theme', function () {
$styles = file_get_contents(resource_path('css/app.css'));
expect($styles)
->toContain('html {')
->toContain('color-scheme: light;')
->toContain('html.dark {')
->toContain('color-scheme: dark;');
});