mirror of
https://github.com/tiennm99/coolify.git
synced 2026-09-03 14:18:12 +00:00
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:
+165
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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" />'
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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;');
|
||||
});
|
||||
Reference in New Issue
Block a user