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:
parent
d9cafc0dd7
commit
6bb8fcd5c7
11 changed files with 593 additions and 315 deletions
|
|
@ -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,
|
||||
|
|
|
|||
185
resources/views/components/theme-controls.blade.php
Normal file
185
resources/views/components/theme-controls.blade.php
Normal 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
|
||||
48
resources/views/components/theme-controls/picker.blade.php
Normal file
48
resources/views/components/theme-controls/picker.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
111
tests/Feature/CustomThemeContrastTest.php
Normal file
111
tests/Feature/CustomThemeContrastTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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]');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue