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.
This commit is contained in:
Aditya Tripathi 2026-09-07 12:11:59 +00:00 committed by Andras Bacsai
parent d9cafc0dd7
commit 6bb8fcd5c7
11 changed files with 593 additions and 315 deletions

View file

@ -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,

View file

@ -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(). --}}
<div x-data="themeControls()" class="grid gap-0.5">
@foreach ([
['value' => 'light', 'label' => 'Light'],
['value' => 'system', 'label' => 'System'],
['value' => 'dark', 'label' => 'Dark'],
['value' => 'custom', 'label' => 'Custom'],
] as $option)
@if ($option['value'] === 'custom')
<div class="relative" @click.outside="pickerOpen = false">
<button type="button" @click="chooseCustom()" aria-haspopup="dialog" :aria-expanded="pickerOpen"
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 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="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<x-theme-controls.picker position="left-full top-0 ml-2" />
</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="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endif
@endforeach
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
<div class="px-2 pt-1 pb-0.5 text-[10px] font-medium tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Page width
</div>
@foreach ([
['value' => 'full', 'label' => 'Full width'],
['value' => 'centered', 'label' => 'Centered'],
] as $option)
<button type="button" @click="setWidth('{{ $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="pageWidth === '{{ $option['value'] }}'"
class="size-3.5 text-coollabs dark:text-warning" viewBox="0 0 12 12" fill="none"
aria-hidden="true">
<path d="{{ $checkPath }}" stroke="currentColor" stroke-width="1.4"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
@endforeach
</div>
@else
{{-- Full card grid for the Appearance settings page. --}}
<div x-data="themeControls()" class="mt-8 flex w-full max-w-none flex-col gap-6 lg:mt-3">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Color theme</h2>
<p>Choose the color scheme used in this browser.</p>
</div>
</div>
<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' => '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). --}}
<div class="relative" @click.outside="pickerOpen = false">
<div role="button" tabindex="0" @click="chooseCustom()" @keydown.enter.prevent="chooseCustom()"
aria-haspopup="dialog" :aria-expanded="pickerOpen"
class="group block w-full 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.05] dark:hover:border-white/[0.12]"
:class="theme === 'custom'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="h-20 border-b border-neutral-200 dark:border-white/[0.07]"
:style="`background: color-mix(in oklab, ${themeColor} 28%, ${customMode === 'light' ? 'oklch(97% 0 0)' : 'oklch(17.35% 0.0020 286.18)'})`">
<div class="flex h-full items-center justify-center">
<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>
</div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">{{ $option['label'] }}</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="theme === 'custom'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
{{ $option['description'] }}
</p>
</div>
</div>
<x-theme-controls.picker position="left-0 top-full mt-2" />
</div>
@else
<div role="button" tabindex="0"
@click="setTheme('{{ $option['value'] }}')"
@keydown.enter.prevent="setTheme('{{ $option['value'] }}')"
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.05] 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="flex h-full items-center justify-center">
<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>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">
{{ $option['label'] }}
</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="theme === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
{{ $option['description'] }}
</p>
</div>
</div>
@endif
@endforeach
</div>
</section>
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Page width</h2>
<p>Choose how content uses the available browser width.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-3 sm:grid-cols-2">
@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)
<button type="button" @click="setWidth('{{ $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.05] dark:hover:border-white/[0.12]"
:class="pageWidth === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="flex h-20 items-center border-b border-neutral-200 bg-neutral-50 px-4 dark:border-white/[0.07] dark:bg-black/15">
<div class="flex h-11 w-full gap-1.5 rounded-md border border-neutral-300 bg-white p-1.5 dark:border-white/15 dark:bg-[#181818]">
<div class="w-3 shrink-0 rounded-sm bg-neutral-200 dark:bg-white/10"></div>
<div @class([
'h-full rounded-sm bg-neutral-200 dark:bg-white/10',
'w-full' => $option['value'] === 'full',
'mx-auto w-2/3' => $option['value'] === 'centered',
])></div>
</div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">{{ $option['label'] }}</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="pageWidth === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">{{ $option['description'] }}</p>
</div>
</button>
@endforeach
</div>
</section>
</div>
@endif

View file

@ -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. --}}
<div x-show="pickerOpen" x-cloak
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1 scale-[0.98]"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-[0.98]"
role="dialog" aria-label="Custom color"
class="absolute z-[60] w-60 origin-top {{ $position }} rounded-xl border border-neutral-200 bg-white p-3 text-left shadow-dropdown dark:border-white/[0.1] dark:bg-panel">
<div class="mb-2 text-[11px] font-semibold tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Custom color
</div>
<label class="flex cursor-pointer items-center gap-2.5 rounded-lg border border-neutral-200 p-2 transition-colors hover:border-neutral-300 dark:border-white/[0.08] dark:hover:border-white/[0.14]">
<span class="relative size-8 shrink-0 overflow-hidden rounded-md border border-black/10 shadow-sm dark:border-white/15">
<span class="absolute inset-0" :style="`background: ${themeColor}`"></span>
<input type="color" :value="themeColor" @input="previewThemeColor($event.target.value)"
@change="saveThemeColor($event.target.value)" aria-label="Custom theme color"
class="absolute inset-0 h-full w-full cursor-pointer opacity-0" />
</span>
<span class="flex min-w-0 flex-col">
<span class="text-xs font-medium text-black dark:text-fg">Pick a color</span>
<span class="text-[11px] tracking-wide text-neutral-500 uppercase dark:text-fg-dim" x-text="themeColor"></span>
</span>
</label>
<div class="mt-2 grid grid-cols-2 gap-1 rounded-lg border border-neutral-200 p-0.5 dark:border-white/[0.08]"
role="group" aria-label="Custom theme mode">
<button type="button" @click="setCustomMode('light')" :aria-pressed="customMode === 'light'"
class="rounded-md py-1.5 text-xs font-medium transition-colors"
:class="customMode === 'light'
? 'bg-neutral-100 text-black shadow-sm dark:bg-white/[0.14] dark:text-fg'
: 'text-neutral-500 hover:text-black dark:text-fg-dim dark:hover:text-fg'">
Light
</button>
<button type="button" @click="setCustomMode('dark')" :aria-pressed="customMode === 'dark'"
class="rounded-md py-1.5 text-xs font-medium transition-colors"
:class="customMode === 'dark'
? 'bg-neutral-100 text-black shadow-sm dark:bg-white/[0.14] dark:text-fg'
: 'text-neutral-500 hover:text-black dark:text-fg-dim dark:hover:text-fg'">
Dark
</button>
</div>
</div>

View file

@ -11,10 +11,6 @@
<div @class(['relative', 'min-w-0' => $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()">
<button type="button" @click="open ? closePanel() : openPanel()"
@ -129,63 +80,8 @@ class="flex size-5 shrink-0 items-center justify-center rounded-full bg-neutral-
stroke-linejoin="round" />
</svg>
</button>
<div x-show="appearanceOpen" x-collapse.duration.200ms class="mx-1 grid gap-0.5 pb-1 pl-6">
@foreach ([
['value' => 'light', 'label' => 'Light'],
['value' => 'system', 'label' => 'System'],
['value' => 'dark', 'label' => 'Dark'],
['value' => 'custom', 'label' => 'Custom'],
] as $option)
@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" :value="themeColor" @input="previewThemeColor($event.target.value)"
@change="saveThemeColor($event.target.value)"
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 class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>
<div class="px-2 pt-1 pb-0.5 text-[10px] font-medium tracking-wide text-neutral-400 uppercase dark:text-fg-faint">
Page width
</div>
@foreach ([
['value' => 'full', 'label' => 'Full width'],
['value' => 'centered', 'label' => 'Centered'],
] as $option)
<button type="button" @click="setWidth('{{ $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="pageWidth === '{{ $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>
@endforeach
<div x-show="appearanceOpen" x-collapse.duration.200ms class="mx-1 pb-1 pl-6">
<x-theme-controls variant="menu" />
</div>
<div class="my-1 h-px bg-neutral-200 dark:bg-white/[0.07]"></div>

View file

@ -70,10 +70,9 @@ class="flex h-full items-center gap-0.5 min-w-0 flex-1 border-b border-neutral-2
</header>
{{-- ============ MOBILE SLIDE-OVER SIDEBAR (shadcn-style sheet) ============ --}}
<div class="mobile-sidebar-sheet relative z-[1000] lg:hidden" role="dialog" aria-modal="true"
<div class="mobile-sidebar-sheet relative z-[1000] lg:hidden"
x-on:keydown.escape.window="open = false"
x-on:livewire:navigated.window="open = false"
x-effect="document.body.classList.toggle('overflow-hidden', open)">
x-on:livewire:navigated.window="open = false">
{{-- Scrim: fades in/out --}}
<div class="fixed inset-0 bg-black/50" x-show="open" x-cloak x-on:click="open = false"
x-transition:enter="transition-opacity ease-out duration-300"
@ -82,7 +81,8 @@ class="flex h-full items-center gap-0.5 min-w-0 flex-1 border-b border-neutral-2
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"></div>
{{-- Panel: slides in from the right (iOS drawer curve), exits faster --}}
<div class="fixed inset-y-0 right-0 flex" :class="!open && 'pointer-events-none'">
<div x-show="open" x-cloak
<div x-show="open" x-cloak x-trap.inert.noscroll="open"
role="dialog" aria-modal="true" aria-label="Navigation menu"
x-transition:enter="transform transition ease-[cubic-bezier(0.32,0.72,0,1)] duration-300"
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in duration-200"

View file

@ -3,6 +3,22 @@
<script data-navigate-once>
// Immediate theme application - runs before any rendering
(function () {
// The OS color picker only speaks hex. Convert it once so the whole theme
// cascade is authored in OKLCH (sRGB -> linear -> OKLab -> OKLCH).
const srgbToLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
window.hexToOklch = (hex) => {
const [r, g, b] = hex.match(/[a-f\d]{2}/gi).map((channel) => srgbToLinear(parseInt(channel, 16) / 255));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
const okL = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
const okA = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
const okB = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
const chroma = Math.sqrt(okA * okA + okB * okB);
let hue = Math.atan2(okB, okA) * 180 / Math.PI;
if (hue < 0) hue += 360;
return `oklch(${(okL * 100).toFixed(2)}% ${chroma.toFixed(4)} ${hue.toFixed(2)})`;
};
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
@ -10,20 +26,93 @@
.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';
return luminance > 0.179 ? 'oklch(0% 0 0)' : 'oklch(100% 0 0)';
};
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);
const customMode = localStorage.customMode || 'dark';
const isDark = theme === 'dark'
|| (theme === 'custom' && customMode === 'dark')
|| (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-base-color', window.hexToOklch(themeColor));
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
};
// Single source for the theme controls Alpine state, shared by the
// Appearance page and the profile dropdown via x-data="themeControls()".
window.themeControls = () => ({
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
customMode: localStorage.getItem('customMode') || 'dark',
pageWidth: localStorage.getItem('pageWidth') || 'full',
themeColorFrame: null,
pickerOpen: false,
init() {
localStorage.setItem('theme', this.theme);
this.applyTheme();
},
chooseCustom() {
this.setTheme('custom');
this.pickerOpen = !this.pickerOpen;
},
setTheme(type) {
this.theme = type;
localStorage.setItem('theme', type);
this.applyTheme();
},
setCustomMode(mode) {
this.customMode = mode;
localStorage.setItem('customMode', mode);
if (this.theme !== 'custom') {
this.setTheme('custom');
return;
}
this.applyTheme();
},
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';
localStorage.setItem('theme', 'custom');
}
document.documentElement.dataset.theme = 'custom';
document.documentElement.classList.toggle('dark', this.customMode === 'dark');
if (this.themeColorFrame) {
return;
}
this.themeColorFrame = requestAnimationFrame(() => {
document.documentElement.style.setProperty('--theme-base-color', window.hexToOklch(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');
},
applyTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = this.theme === 'dark'
|| (this.theme === 'custom' && this.customMode === 'dark')
|| (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', window.hexToOklch(this.themeColor));
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(this.themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
},
});
document.addEventListener('livewire:navigated', window.applyStoredTheme);
window.applyStoredTheme();

View file

@ -1,156 +1,4 @@
<div>
<x-slot:title>Appearance | Coolify</x-slot>
<div x-data="{
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: localStorage.getItem('themeColor') || '#6b16ed',
pageWidth: localStorage.getItem('pageWidth') || 'full',
themeColorFrame: null,
init() {
localStorage.setItem('theme', this.theme);
this.applyTheme();
},
setTheme(type) {
this.theme = type;
localStorage.setItem('theme', type);
this.applyTheme();
},
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');
},
applyTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
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-none flex-col gap-6 lg:mt-3">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Color theme</h2>
<p>Choose the color scheme used in this browser.</p>
</div>
</div>
<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)
<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.05] 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]"
@if ($option['value'] === 'custom') :style="`background: color-mix(in oklab, ${themeColor} 28%, #101011)`" @endif>
<div class="flex h-full items-center justify-center">
@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>
@endif
</div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">
{{ $option['label'] }}
</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="theme === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
{{ $option['description'] }}
</p>
</div>
@if ($option['value'] === 'custom')
<input type="color" :value="themeColor" @input="previewThemeColor($event.target.value)"
@change="saveThemeColor($event.target.value)"
aria-label="Custom theme color"
class="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0" />
@endif
</div>
@endforeach
</div>
</section>
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Page width</h2>
<p>Choose how content uses the available browser width.</p>
</div>
</div>
<div class="application-settings-section-body grid gap-3 sm:grid-cols-2">
@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)
<button type="button" @click="setWidth('{{ $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.05] dark:hover:border-white/[0.12]"
:class="pageWidth === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-warning/30 dark:border-warning/40'
: ''">
<div class="flex h-20 items-center border-b border-neutral-200 bg-neutral-50 px-4 dark:border-white/[0.07] dark:bg-black/15">
<div class="flex h-11 w-full gap-1.5 rounded-md border border-neutral-300 bg-white p-1.5 dark:border-white/15 dark:bg-[#181818]">
<div class="w-3 shrink-0 rounded-sm bg-neutral-200 dark:bg-white/10"></div>
<div @class([
'h-full rounded-sm bg-neutral-200 dark:bg-white/10',
'w-full' => $option['value'] === 'full',
'mx-auto w-2/3' => $option['value'] === 'centered',
])></div>
</div>
</div>
<div class="p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-black dark:text-fg">{{ $option['label'] }}</span>
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-warning"
x-show="pageWidth === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">{{ $option['description'] }}</p>
</div>
</button>
@endforeach
</div>
</section>
</div>
<x-theme-controls variant="full" />
</div>

View file

@ -3,6 +3,8 @@
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'));
$component = file_get_contents(resource_path('views/components/theme-controls.blade.php'));
$picker = file_get_contents(resource_path('views/components/theme-controls/picker.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'));
@ -11,22 +13,23 @@
$utilities = file_get_contents(resource_path('css/utilities.css'));
expect($appearance)
->toContain('<x-theme-controls variant="full"')
->and($accountMenu)
->toContain('<x-theme-controls variant="menu"')
->and($component)
->toContain("['value' => 'custom', 'label' => 'Custom'")
->toContain('<x-theme-controls.picker')
->and($picker)
->toContain('type="color"')
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->toContain('requestAnimationFrame(() =>')
->toContain('@input="previewThemeColor($event.target.value)"')
->toContain('@change="saveThemeColor($event.target.value)"')
->toContain('aria-label="Custom theme color"')
->toContain('setCustomMode')
->and($layout)
->toContain('window.themeControls')
->toContain('requestAnimationFrame(() =>')
->toContain("localStorage.setItem('themeColor', color)")
->toContain("this.theme === 'custom'")
->and($accountMenu)
->toContain("['value' => 'custom', 'label' => 'Custom']")
->toContain('aria-label="Custom theme color"')
->toContain('requestAnimationFrame(() =>')
->toContain('@input="previewThemeColor($event.target.value)"')
->toContain('@change="saveThemeColor($event.target.value)"')
->toContain("localStorage.setItem('themeColor', color)")
->toContain('absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0')
->and($layout)
->toContain("t === 'custom'")
->toContain("localStorage.themeColor || '#6b16ed'")
@ -35,34 +38,35 @@
->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('--theme-base-color: oklch(49.65% 0.2709 289.33);')
->toContain('--theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, oklch(100% 0 0));')
->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('--color-fg-dim: oklch(91.08% 0.0157 306.4);')
->toContain('--color-fg-faint: oklch(87.24% 0.0188 306.63);')
->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 20%, var(--theme-base-color));')
->toContain('--theme-border-color: color-mix(in oklab, var(--theme-base-color) 42%, oklch(44.19% 0.0146 285.79));')
->toContain('--theme-fg-on-surface: oklch(from var(--theme-base-color) clamp(0.72, l, 0.86) min(c, 0.13) h);')
->toContain('--theme-placeholder-color: oklch(from var(--theme-base-color) 0.62 min(c, 0.03) h);')
->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('--color-panel: color-mix(in oklab, var(--theme-base-color) 14%, oklch(15.48% 0.0021 286.15));')
->toContain('--color-surface: color-mix(in oklab, var(--theme-base-color) 18%, oklch(17.35% 0.0020 286.18));')
->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('--coollabs-base: color-mix(in oklab, var(--theme-base-color) 22%, oklch(17.81% 0.0020 286.19));')
->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('--coollabs-fill: color-mix(in oklab, var(--theme-base-color) 32%, oklch(20.99% 0.0039 286.06));')
->toContain('--color-nav-text: oklch(91.08% 0.0157 306.4);')
->toContain('--color-nav-muted: oklch(87.24% 0.0188 306.63);')
->toContain('--color-log: color-mix(in oklab, var(--theme-base-color) 20%, oklch(13.49% 0.0024 286.07));')
->toContain('--color-log-toolbar: color-mix(in oklab, var(--theme-base-color) 26%, oklch(17.35% 0.0020 286.18));')
->toContain('html[data-theme="custom"] .logs-viewer-toolbar')
->toContain('html[data-theme="custom"] .logs-viewer-timestamp')
->toContain('html[data-theme="custom"] #nprogress .bar')
@ -78,7 +82,7 @@
->toContain('border-color: var(--coollabs-hairline) !important;')
->toContain('html[data-theme="custom"] input::placeholder')
->toContain('color: var(--theme-placeholder-color) !important;')
->toMatch('/html\[data-theme="custom"\] input::placeholder,[^{]+\{[^}]*opacity: 0\.7;/s')
->toMatch('/html\[data-theme="custom"\] input::placeholder,[^{]+\{[^}]*opacity: 1;/s')
->toContain('html[data-theme="custom"] input:read-only')
->and($deploymentLogs)
->toContain('dark:bg-log')

View file

@ -0,0 +1,111 @@
<?php
/**
* The custom theme derives its whole palette from one user-picked color.
* These tests lock in two guarantees:
* 1. The pipeline is authored entirely in OKLCH (no hex color literals).
* 2. Foreground colors (accent text, placeholders) are clamped so they stay
* readable on the dark surfaces regardless of the picked hue.
*/
function customThemeBlock(): string
{
$css = file_get_contents(resource_path('css/app.css'));
return str($css)
->after('html[data-theme="custom"] {')
->before('}')
->toString();
}
it('derives the custom theme entirely in oklch with no hex color literals', function () {
expect(customThemeBlock())
->not->toMatch('/#[0-9a-fA-F]{3,8}\b/')
->toContain('--theme-base-color:')
->toContain('oklch(');
});
it('adds clamped, readable foreground tokens for the custom theme', function () {
$block = customThemeBlock();
expect($block)
// Accent text/icons on dark surfaces: lightness forced into a readable band.
->toContain('--theme-fg-on-surface:')
->toMatch('/--theme-fg-on-surface:\s*oklch\(from var\(--theme-base-color\)[^;]*clamp\(/')
// Placeholder: light, near-neutral, guaranteed readable.
->toContain('--theme-placeholder-color:')
->toMatch('/--theme-placeholder-color:\s*oklch\(from var\(--theme-base-color\)/');
});
it('routes custom-theme accent text utilities to the readable on-surface token', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toMatch('/\[class~="text-warning"\][^{]*\{\s*color:\s*var\(--theme-fg-on-surface\)/')
->toContain('[class~="text-coollabs"]');
});
it('uses the clamped placeholder token for custom-theme inputs', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toMatch('/html\[data-theme="custom"\] input::placeholder[^}]*var\(--theme-placeholder-color\)/');
});
it('adds a light custom variant that tints surfaces toward white with dark foreground', function () {
$css = file_get_contents(resource_path('css/app.css'));
expect($css)
->toContain('html[data-theme="custom"]:not(.dark)')
// Surfaces hold a fixed high lightness with injected base-hue chroma, so the
// tint is visible (not a washed-out mix into white).
->toMatch('/html\[data-theme="custom"\]:not\(\.dark\)[^}]*--color-app:\s*oklch\(from var\(--theme-base-color\) 9[0-9](\.[0-9]+)?% min\(c,/s')
// Accent text/icons clamp to a dark, readable band on light surfaces.
->toMatch('/html\[data-theme="custom"\]:not\(\.dark\)[^}]*--theme-fg-on-surface:\s*oklch\(from var\(--theme-base-color\) clamp\(0\.3/s');
});
it('lets the theme scripts toggle a custom light or dark mode via customMode', function () {
$base = file_get_contents(resource_path('views/layouts/base.blade.php'));
$component = file_get_contents(resource_path('views/components/theme-controls.blade.php'));
$picker = file_get_contents(resource_path('views/components/theme-controls/picker.blade.php'));
$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'));
expect($base)
// One shared Alpine factory holds the theme logic.
->toContain('window.themeControls')
->toContain('localStorage.customMode')
->toContain('setCustomMode')
// The picker no longer force-adds dark; it follows customMode.
->not->toContain("document.documentElement.classList.add('dark')")
// Light/dark lives inside the custom color picker popover.
->and($picker)
->toContain('customMode')
->toContain('setCustomMode')
->toContain('type="color"')
->and($component)
// Both variants open the shared picker popover for Custom.
->toContain('<x-theme-controls.picker')
->toContain('chooseCustom')
// Both the page and the dropdown reuse the one component.
->and($appearance)
->toContain('<x-theme-controls variant="full"')
->and($accountMenu)
->toContain('<x-theme-controls variant="menu"');
});
it('computes the theme base color and accent foreground in oklch, not hex', function () {
$base = file_get_contents(resource_path('views/layouts/base.blade.php'));
$accentFn = str($base)
->after('window.themeAccentForeground')
->before('window.applyStoredTheme')
->toString();
expect($base)
->toContain('window.hexToOklch')
->and($accentFn)
->toContain('oklch(0% 0 0)')
->toContain('oklch(100% 0 0)')
->not->toContain('#000000')
->not->toContain('#ffffff');
});

View file

@ -180,8 +180,8 @@
expect($view)
->toContain('data-terminal-session-canvas')
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
->toContain('terminal-session-panel mt-8')
->toContain('terminal-session-toolbar flex items-center gap-3 text-white select-none')
->toContain('terminal-session-panel flex min-h-0 flex-1 flex-col overflow-hidden p-3 sm:p-6')
->toContain('<x-terminal.theme-selector')
->not->toContain('application-console-header flex h-[30px]');
});

View file

@ -90,7 +90,7 @@
expect($view)
->toContain('data-terminal-session-canvas')
->toContain('p-3 sm:p-6')
->toContain('terminal-session-panel mt-8')
->toContain('terminal-session-panel flex min-h-0 flex-1 flex-col overflow-hidden p-3 sm:p-6')
->and($styles)
->toContain('.terminal-session-panel')
->toContain('border-radius: 0.75rem;')
@ -145,7 +145,7 @@
->and($selector)
->toContain('terminal-theme-trigger flex h-8 items-center gap-2 rounded-md px-2.5 text-xs font-medium')
->and($view)
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
->toContain('terminal-session-toolbar flex items-center gap-3 text-white select-none')
->not->toContain('terminal-theme-trigger');
});
@ -177,7 +177,7 @@
$view = file_get_contents(resource_path('views/livewire/terminal/index.blade.php'));
expect($view)
->toContain('terminal-session-toolbar absolute top-3 right-3 left-3')
->toContain('terminal-session-toolbar flex items-center gap-3 text-white select-none')
->toContain('<x-terminal.theme-selector')
->not->toContain('class="absolute top-3 right-3 z-20"');
});
@ -256,7 +256,7 @@
->and($appCss)
->toContain('[data-console-theme="system"]')
->toContain('html:not(.dark) .application-console-shell[data-console-theme="system"]')
->toContain('--console-theme-border: #d4d4d8')
->toContain('--console-theme-border: oklch(87.11% 0.0055 286.29)')
->toContain('background: transparent')
->toContain('html.dark .application-console-shell[data-console-theme="system"]');
});