From 6b41899d5b5d747ace368a5ee345d9e56b81b115 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:59:41 +0200 Subject: [PATCH] feat(ui): add custom theme color and polish light-mode theming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- resources/css/app.css | 167 +++++++++++++++++- resources/css/utilities.css | 12 +- resources/js/terminal.js | 20 ++- resources/views/components/helper.blade.php | 6 +- resources/views/components/navbar.blade.php | 6 +- .../components/server-timing-hud.blade.php | 14 ++ .../views/components/top-user-menu.blade.php | 52 ++++-- resources/views/layouts/base.blade.php | 34 +++- .../livewire/profile/appearance.blade.php | 43 ++++- .../application/deployment/show.blade.php | 4 +- tests/Feature/AppearanceThemeTest.php | 137 ++++++++++++++ tests/Feature/EmptyStateComponentTest.php | 6 + tests/Feature/HelperInfoButtonTest.php | 8 + tests/Feature/LightModeScrollbarTest.php | 11 ++ 14 files changed, 476 insertions(+), 44 deletions(-) create mode 100644 tests/Feature/AppearanceThemeTest.php create mode 100644 tests/Feature/LightModeScrollbarTest.php diff --git a/resources/css/app.css b/resources/css/app.css index 2ec644306..594e622e4 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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); } diff --git a/resources/css/utilities.css b/resources/css/utilities.css index ac5593d16..bd69dedee 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -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 { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 25faa77c0..26e32bb6f 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -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 . diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php index 50e74baf5..fb6364973 100644 --- a/resources/views/components/helper.blade.php +++ b/resources/views/components/helper.blade.php @@ -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); diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index b543ae040..d5637f5be 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -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 --}} diff --git a/resources/views/components/server-timing-hud.blade.php b/resources/views/components/server-timing-hud.blade.php index 45f6d4500..f17f05bad 100644 --- a/resources/views/components/server-timing-hud.blade.php +++ b/resources/views/components/server-timing-hud.blade.php @@ -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; diff --git a/resources/views/components/top-user-menu.blade.php b/resources/views/components/top-user-menu.blade.php index c1a4c80d7..58d1fe4a4 100644 --- a/resources/views/components/top-user-menu.blade.php +++ b/resources/views/components/top-user-menu.blade.php @@ -11,17 +11,24 @@
$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"> + @if ($option['value'] === 'custom') +
+ + + Custom + + + +
+ @else + + @endif @endforeach
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 94b03e6b3..38ee3264a 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -1,12 +1,32 @@ - @@ -54,7 +74,7 @@