feat(ui): add four-color custom theme dialog with presets and share code

This commit is contained in:
Andras Bacsai
2026-10-23 22:07:21 +02:00
parent 9e0823becc
commit 582399c382
7 changed files with 625 additions and 238 deletions
+12 -8
View File
@@ -162,12 +162,16 @@ Rules for the tokens:
`color-mix(in srgb, var(--color-brand) 14%, transparent)`. A custom theme
(`html[data-theme="custom"]`) overrides only these tokens, so a hard-coded
value stays purple when the user picks another color.
- The custom theme derives every token from the picked color: the brand fill
and its hover, press, depth, and ring shades; `--color-brand-foreground`
(the computed `--theme-accent-foreground`, so text stays readable on a light
color such as yellow); `--color-card` (the tinted surface step); and
`--color-sidebar` (between the tinted canvas and panel). When you add a
dark-theme color token, give it a custom-theme value in the same block.
- The custom theme has four colors: Sidebar, Selected (the current page in
the sidebar), Accent, and Background. The accent gives the brand fill and its
hover, press, depth, and ring shades, and `--color-brand-foreground` (the
computed `--theme-accent-foreground`, so text stays readable on a light color
such as yellow). The surface, border, and text tokens come from one ladder
in `app.css` that mixes the readable text color into the background. The
sidebar uses the same ladder with the sidebar color, and it can have the
other light/dark scheme than the page (`data-sidebar-scheme`; `dark:`
utilities follow it). When you add a color token, give it a value in that
ladder.
- A translucent fill on a control that sits directly on the page (not in a
card or table) shows the floating-navigation dots through it. Mix the tint
with the canvas instead:
@@ -693,8 +697,8 @@ value. The dark ladder is `--color-app` 10, `--color-panel` 20,
Temperature: the page canvas, cards, menus, listboxes, modals, and logs stay
**pure neutral gray** (r=g=b). Do not tint those. The dark sidebar
(`--color-sidebar`, `#0d0d0d`) is neutral too. The dark command palette is the
only exception: it carries a faint brand-purple wash. A custom theme tints all
of these surfaces with the picked color (see the token rules in P2). The light page canvas uses `bg-neutral-50` (not
only exception: it carries a faint brand-purple wash. A custom theme derives all
of these surfaces from its background and sidebar colors (see the token rules in P2). The light page canvas uses `bg-neutral-50` (not
`bg-gray-50`, which is faintly cool) so it matches the neutral cards and chrome.
The only intentional color is the purple brand accent. Yellow is for warnings.
- **Layer cards** use the outer box and inset well from section 5.
+125 -75
View File
@@ -12,7 +12,9 @@
@source '../../storage/framework/views/*.php';
@custom-variant dark (&:where(.dark, .dark *));
/* A custom theme can give the sidebar the other scheme than the page
(data-sidebar-scheme on <html>). dark: utilities follow the sidebar there. */
@custom-variant dark (&:where(.dark, .dark *, [data-sidebar-scheme="dark"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]), [data-sidebar-scheme="dark"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) *):not(:where([data-sidebar-scheme="light"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]), [data-sidebar-scheme="light"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) *)));
@theme {
--font-sans: 'Geist Sans', Inter, sans-serif;
@@ -1374,19 +1376,24 @@ html:not(.dark) {
--color-error: oklch(52% 0.2 27.33);
}
/* Theme surfaces are derived from one brand color. Changing
--theme-base-color is enough to generate a complete dark surface ladder. */
/* Custom theme. applyCustomTheme() in base.blade.php sets four colors and the
text color that is readable on each. The page and the sidebar each derive
one surface ladder from three variables:
--t-bg the background color
--t-text the text color on it
--t-light 1 for a light background, 0 for a dark one
Light schemes put white cards on a slightly darker canvas. Dark schemes lift
cards toward the text color. */
html[data-theme="custom"] {
--t-bg: var(--theme-bg);
--t-text: var(--theme-text);
--t-light: var(--theme-light);
--theme-base-color: oklch(49.65% 0.2709 289.33);
--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%, 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%, oklch(100% 0 0));
@@ -1403,83 +1410,58 @@ html[data-theme="custom"] {
--color-brand-ring: color-mix(in oklab, var(--theme-bright-color) 75%, oklch(100% 0 0));
--color-brand-foreground: var(--theme-accent-foreground);
--color-brand-light: var(--theme-fg-on-surface);
/* Card wells, tables, and tiles (#121212 in the neutral dark theme). */
--color-card: var(--color-surface);
/* Sidebar: one step above the canvas, like the neutral dark theme. */
--color-sidebar: color-mix(in oklab, var(--color-app) 70%, var(--color-panel));
--color-accent-foreground: var(--theme-accent-foreground);
--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%, 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%, oklch(17.81% 0.0020 286.19));
--color-content-surface: var(--coollabs-base);
--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%, 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);
--color-sidebar: var(--theme-sidebar);
}
/* 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);
}
html[data-theme="custom"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) {
--t-bg: var(--theme-sidebar);
--t-text: var(--theme-sidebar-text);
--t-light: var(--theme-sidebar-light);
}
html[data-theme="custom"],
html[data-theme="custom"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) {
--color-app: color-mix(in oklab, var(--t-text) calc(var(--t-light) * 3%), var(--t-bg));
--color-panel: color-mix(in oklab, var(--t-text) calc((1 - var(--t-light)) * 3%), var(--t-bg));
--color-surface: color-mix(in oklab, var(--t-text) calc((1 - var(--t-light)) * 4%), var(--t-bg));
--color-raised: color-mix(in oklab, var(--t-text) calc(4% + (1 - var(--t-light)) * 2%), var(--t-bg));
--color-selected: color-mix(in oklab, var(--t-text) calc(8% + (1 - var(--t-light)) * 2%), var(--t-bg));
--color-card: var(--color-surface);
--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);
--color-coolgray-400: color-mix(in oklab, var(--t-text) calc(9% + (1 - var(--t-light)) * 3%), var(--t-bg));
--color-coolgray-500: color-mix(in oklab, var(--t-text) calc(12% + (1 - var(--t-light)) * 3%), var(--t-bg));
--coollabs-canvas: var(--color-app);
--coollabs-elevated: oklch(from var(--theme-base-color) 98.5% min(c, 0.025) h);
--coollabs-elevated: var(--color-surface);
--coollabs-recessed: var(--color-raised);
--coollabs-base: oklch(from var(--theme-base-color) 99.3% min(c, 0.018) h);
--coollabs-base: color-mix(in oklab, var(--t-text) calc((1 - var(--t-light)) * 5%), var(--t-bg));
--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);
--coollabs-fill: color-mix(in oklab, var(--t-text) calc(8% + (1 - var(--t-light)) * 4%), var(--t-bg));
--coollabs-line: color-mix(in oklab, var(--t-text) calc(16% + (1 - var(--t-light)) * 6%), var(--t-bg));
--coollabs-hairline: color-mix(in oklab, var(--t-text) calc(11% + (1 - var(--t-light)) * 3%), var(--t-bg));
--color-hairline: color-mix(in oklab, var(--t-text) 8%, transparent);
--color-fg: var(--t-text);
--color-fg-dim: color-mix(in oklab, var(--t-text) 74%, var(--t-bg));
--color-fg-faint: color-mix(in oklab, var(--t-text) 60%, var(--t-bg));
--coollabs-subtle: var(--color-fg-faint);
--theme-placeholder-color: color-mix(in oklab, var(--t-text) 50%, var(--t-bg));
--color-nav-text: color-mix(in oklab, var(--t-text) 74%, var(--t-bg));
--color-nav-muted: color-mix(in oklab, var(--t-text) 60%, var(--t-bg));
--color-nav-active: var(--t-text);
/* Logs sit a little deeper than the canvas. */
--color-log: color-mix(in oklab, oklch(0% 0 0) calc((1 - var(--t-light)) * 20%), var(--color-app));
--color-log-toolbar: var(--color-surface);
}
html[data-theme="custom"] .control-selected,
html[data-theme="custom"] .logs-viewer-btn-active {
color: var(--color-accent-foreground);
@@ -1572,24 +1554,30 @@ html[data-theme="custom"] [class~="dark:hover:text-coollabs"]:hover {
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"]) {
whole UI, not just design-system classes. A light sidebar on a dark page
gets the same remap. */
html[data-theme="custom"]:not(.dark) :is([class~="bg-white"], [class~="bg-white/95"], [class~="bg-white/80"]),
html[data-sidebar-scheme="light"] :where(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) :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"] {
html[data-theme="custom"]:not(.dark) [class~="bg-neutral-50"],
html[data-sidebar-scheme="light"] :where(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) [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"]) {
html[data-theme="custom"]:not(.dark) :is([class~="bg-neutral-100"], [class~="bg-neutral-200"]),
html[data-sidebar-scheme="light"] :where(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) :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"] {
html[data-theme="custom"]:not(.dark) [class~="border-neutral-200"],
html[data-sidebar-scheme="light"] :where(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) [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"]) {
html[data-theme="custom"]:not(.dark) :is([class~="border-neutral-300"], [class~="border-neutral-100"]),
html[data-sidebar-scheme="light"] :where(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) :is([class~="border-neutral-300"], [class~="border-neutral-100"]) {
border-color: var(--coollabs-line) !important;
}
@@ -6339,6 +6327,68 @@ html.dark .app-sidebar .menu-item:not(:hover):not(:focus-visible):not(.menu-item
opacity: 0;
}
/* Custom theme sidebar. These rules come after the dark sidebar rules above, so they win. */
/* !important: the light custom theme maps bg-neutral-50 to the canvas with !important. */
html[data-theme="custom"] :is(.app-sidebar nav.sidebar-nav, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) {
background-color: var(--theme-sidebar) !important;
}
html[data-theme="custom"] .mobile-sidebar-sheet nav.sidebar-nav {
background-color: transparent !important;
}
/* The current page in the sidebar uses the Selected color. */
html[data-theme="custom"] :is(.app-sidebar, .mobile-sidebar-sheet) :is(.menu-item-active, .menu-subitem-active) {
background-color: var(--theme-selected);
color: var(--theme-selected-text);
}
/* The 3D style shows the current page as an accent tile, like the Dark theme, on a light or dark sidebar. */
html[data-theme="custom"]:where(:not([data-nav-items="flat"])) :is(.app-sidebar, .mobile-sidebar-sheet) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon:not(.text-error) {
color: var(--color-brand-foreground);
border-color: var(--color-brand-ring) !important;
background: linear-gradient(180deg, var(--color-brand-hover) 0%, var(--color-brand-press) 100%) !important;
--button-depth-color: var(--color-brand-depth) !important;
box-shadow: 0 2px 0 var(--color-brand-depth) !important;
}
/* 3D icon tiles and the search field take their colors from the sidebar text,
so they fit a light or a dark sidebar. */
html[data-theme="custom"]:where(:not([data-nav-items="flat"])) :is(.app-sidebar, .mobile-sidebar-sheet) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):not(.menu-search):not(.menu-item-active):not(.menu-subitem-active) .menu-item-icon {
border-color: color-mix(in oklab, var(--t-text) 15%, transparent);
background: color-mix(in oklab, var(--t-text) 5%, transparent);
color: var(--color-nav-text);
--button-depth-color: color-mix(in oklab, var(--t-text) 12%, transparent);
}
html[data-theme="custom"]:where(:not([data-nav-items="flat"])) :is(.app-sidebar, .mobile-sidebar-sheet) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):not(.menu-search):not(.menu-item-active):not(.menu-subitem-active):hover .menu-item-icon {
border-color: color-mix(in oklab, var(--t-text) 20%, transparent);
background: color-mix(in oklab, var(--t-text) 9%, transparent);
color: var(--color-nav-active);
}
@layer utilities {
html[data-theme="custom"]:where(:not([data-nav-items="flat"])) :is(.app-sidebar, .mobile-sidebar-sheet) nav:not(.sidebar-collapsed) .menu-search.menu-item {
background-color: color-mix(in oklab, var(--t-text) 6%, transparent) !important;
}
html[data-theme="custom"]:where(:not([data-nav-items="flat"])) :is(.app-sidebar, .mobile-sidebar-sheet) nav:not(.sidebar-collapsed) .menu-search.menu-item:hover {
background-color: color-mix(in oklab, var(--t-text) 10%, transparent) !important;
}
}
html[data-theme="custom"] :is(.app-sidebar, .mobile-sidebar-sheet) .sidebar-footer .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled) {
--button-depth-color: color-mix(in oklab, var(--t-text) 8%, transparent);
background-color: color-mix(in oklab, var(--t-text) 5%, transparent);
border-color: color-mix(in oklab, var(--t-text) 12%, transparent);
color: var(--color-nav-text);
}
html[data-theme="custom"] :is(.app-sidebar, .mobile-sidebar-sheet) .sidebar-footer .button:not(.button-highlighted):not(.button-error):not([isHighlighted]):not(:disabled):hover {
background-color: color-mix(in oklab, var(--t-text) 9%, transparent);
border-color: color-mix(in oklab, var(--t-text) 18%, transparent);
}
html.dark:not([data-nav-style="floating"]) .app-topbar {
background-color: var(--color-app);
backdrop-filter: none;
@@ -12,37 +12,28 @@
['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' => ''],
['value' => 'custom', 'label' => 'Custom', 'description' => 'Pick a preset or your own colors.', '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-brand-light/30 dark:border-brand-light/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 role="button" tabindex="0" @click="openCustomDialog()" @keydown.enter.prevent="openCustomDialog()"
aria-haspopup="dialog" :aria-expanded="dialogOpen"
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'
? 'border-coollabs! bg-coollabs/[0.06]! ring-2 ring-coollabs/25 dark:border-brand-light! dark:bg-brand-light/[0.08]! dark:ring-brand-light/25'
: ''">
<div class="flex h-20 items-center justify-center border-b border-neutral-200 bg-neutral-100 dark:border-white/[0.07] dark:bg-black/20">
<x-theme-controls.swatch theme="customTheme" class="h-12 w-24" />
</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-brand-light"
x-show="theme === 'custom'" x-cloak />
</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-black dark:text-fg"
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>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
{{ $option['description'] }}
</p>
</div>
<x-theme-controls.picker position="left-0 top-full mt-2" />
</div>
@else
<div role="button" tabindex="0"
@@ -50,7 +41,7 @@
@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-brand-light/30 dark:border-brand-light/40'
? 'border-coollabs! bg-coollabs/[0.06]! ring-2 ring-coollabs/25 dark:border-brand-light! dark:bg-brand-light/[0.08]! dark:ring-brand-light/25'
: ''">
<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">
@@ -64,7 +55,7 @@
<span class="text-sm font-semibold text-black dark:text-fg">
{{ $option['label'] }}
</span>
<x-reicon name="check-circle" class="size-4 text-black dark:text-fg"
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-brand-light"
x-show="theme === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
@@ -92,7 +83,7 @@
<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-brand-light/30 dark:border-brand-light/40'
? 'border-coollabs! bg-coollabs/[0.06]! ring-2 ring-coollabs/25 dark:border-brand-light! dark:bg-brand-light/[0.08]! dark:ring-brand-light/25'
: ''">
<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]">
@@ -107,7 +98,7 @@
<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-black dark:text-fg"
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-brand-light"
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>
@@ -132,7 +123,7 @@
<button type="button" @click="setNavStyle('{{ $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="navStyle === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-brand-light/30 dark:border-brand-light/40'
? 'border-coollabs! bg-coollabs/[0.06]! ring-2 ring-coollabs/25 dark:border-brand-light! dark:bg-brand-light/[0.08]! dark:ring-brand-light/25'
: ''">
@php($floating = $option['value'] === 'floating')
<div @class([
@@ -159,7 +150,7 @@
<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-black dark:text-fg"
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-brand-light"
x-show="navStyle === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">{{ $option['description'] }}</p>
@@ -184,7 +175,7 @@
<button type="button" @click="setNavItems('{{ $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="navItems === '{{ $option['value'] }}'
? 'ring-1 ring-coollabs/30 border-coollabs/40 dark:ring-brand-light/30 dark:border-brand-light/40'
? 'border-coollabs! bg-coollabs/[0.06]! ring-2 ring-coollabs/25 dark:border-brand-light! dark:bg-brand-light/[0.08]! dark:ring-brand-light/25'
: ''">
@php($tiles = $option['value'] === '3d')
<div class="flex h-20 flex-col justify-center gap-1.5 border-b border-neutral-200 bg-neutral-50 px-4 dark:border-white/[0.07] dark:bg-black/15">
@@ -207,7 +198,7 @@
<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-black dark:text-fg"
<x-reicon name="check-circle" class="size-4 text-coollabs dark:text-brand-light"
x-show="navItems === '{{ $option['value'] }}'" x-cloak />
</div>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">{{ $option['description'] }}</p>
@@ -216,4 +207,159 @@
@endforeach
</div>
</section>
{{-- Custom theme dialog: presets, a preview, four colors, and a share code. Save applies the draft. --}}
<template x-teleport="body">
<div x-show="dialogOpen" x-cloak @keydown.escape.window="dialogOpen = false"
class="fixed inset-0 z-99 overflow-hidden">
<div x-show="dialogOpen" x-transition.opacity.duration.200ms
class="absolute inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"></div>
<div @click.self="dialogOpen = false"
class="relative flex min-h-full items-start justify-center p-2 sm:items-center sm:p-4">
<div x-show="dialogOpen" x-trap.inert.noscroll="dialogOpen" role="dialog" aria-modal="true"
aria-labelledby="custom-theme-title"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="application-settings-form application-settings-section relative flex max-h-[calc(100dvh-1rem)] w-full max-w-5xl flex-col overflow-hidden sm:max-h-[calc(100dvh-2rem)]"
style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)">
<header class="shrink-0">
<div class="min-w-0 flex-1 py-0.5">
<h3 id="custom-theme-title" class="truncate">Custom theme</h3>
<p class="mt-0.5 text-xs text-neutral-500 dark:text-fg-dim">
Colors of Coolify in this browser. Text stays readable on every color.
</p>
</div>
<button type="button" @click="dialogOpen = false"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-neutral-500 outline-0 transition-colors hover:bg-neutral-100 hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg">
<x-reicon name="x" class="size-4" />
<span class="sr-only">Close</span>
</button>
</header>
<template x-if="dialogOpen">
<div class="application-settings-section-body grid min-h-0 flex-1 gap-6 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
<div>
<div class="mb-2 text-xs font-medium text-neutral-500 dark:text-fg-dim">Presets</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<template x-for="preset in presets" :key="preset.name">
<button type="button" @click="setDraft(preset.theme && { ...preset.theme })"
:aria-pressed="isDraftPreset(preset)"
class="group flex flex-col gap-1.5 rounded-lg p-1 text-left outline-0 focus-visible:ring-1 focus-visible:ring-accent">
<x-theme-controls.swatch theme="(preset.theme ?? preset.swatch)"
class="h-14 w-full transition-shadow group-hover:shadow-md"
::class="isDraftPreset(preset) && 'ring-2 ring-coollabs ring-offset-2 ring-offset-white dark:ring-brand-light dark:ring-offset-panel'" />
<span class="truncate text-xs text-neutral-600 dark:text-fg-dim" x-text="preset.name"></span>
</button>
</template>
</div>
</div>
<div class="flex min-w-0 flex-col gap-5">
{{-- Preview of a page with the draft colors. --}}
<div class="flex h-48 overflow-hidden rounded-lg border border-neutral-200 text-xs dark:border-white/[0.08]"
aria-hidden="true">
<div class="flex w-2/5 flex-col gap-0.5 p-2.5"
:style="`background: ${draftColors.sidebar}; color: ${textOn(draftColors.sidebar)}`">
<div class="mb-2 px-2 text-sm font-semibold">Coolify</div>
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 opacity-75">
<x-reicon name="dashboard" class="size-3.5" /> Dashboard
</div>
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 font-medium"
:style="`background: ${draftColors.selected}; color: ${textOn(draftColors.selected)}`">
<x-reicon name="projects" class="size-3.5" /> Projects
</div>
<div class="flex items-center gap-2 rounded-md px-2 py-1.5 opacity-75">
<x-reicon name="servers" class="size-3.5" /> Servers
<span class="ml-auto rounded-full px-1.5 text-[10px] font-semibold"
:style="`background: ${draftColors.accent}; color: ${accentTextOn(draftColors.accent)}`">3</span>
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-2 p-3"
:style="`background: ${draftColors.background}; color: ${textOn(draftColors.background)}`">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold">Projects</span>
<span class="rounded-md px-2 py-1 text-[11px] font-medium"
:style="`background: ${draftColors.accent}; color: ${accentTextOn(draftColors.accent)}`">+ New</span>
</div>
<div class="rounded-md border p-2.5"
:style="`background: color-mix(in oklab, ${textOn(draftColors.background)} 4%, ${draftColors.background}); border-color: color-mix(in oklab, ${textOn(draftColors.background)} 14%, ${draftColors.background})`">
<div class="font-medium">My first project</div>
<div class="mt-0.5 opacity-60">production</div>
</div>
<div class="rounded-md border p-2.5"
:style="`background: color-mix(in oklab, ${textOn(draftColors.background)} 4%, ${draftColors.background}); border-color: color-mix(in oklab, ${textOn(draftColors.background)} 14%, ${draftColors.background})`">
<div class="font-medium">Website</div>
<div class="mt-0.5 opacity-60">staging</div>
</div>
</div>
</div>
<div>
<div class="mb-2 text-xs font-medium text-neutral-500 dark:text-fg-dim">Colors</div>
<div class="flex flex-col gap-2.5">
@foreach ([
'sidebar' => ['Sidebar', 'The navigation on the left'],
'selected' => ['Selected', 'The current page in the sidebar'],
'accent' => ['Accent', 'Buttons, links, and highlights'],
'background' => ['Background', 'Pages, cards, and panels'],
] as $key => [$label, $description])
<div class="flex items-center gap-3">
<label class="relative size-9 shrink-0 cursor-pointer overflow-hidden rounded-md border border-black/10 shadow-sm dark:border-white/15">
<span class="absolute inset-0" :style="`background: ${draftColors.{{ $key }}}`"></span>
{{-- autocomplete="off": Firefox session restore otherwise replays the value (#11813). --}}
<input type="color" autocomplete="off" :value="draftColors.{{ $key }}"
@input="setDraftColor('{{ $key }}', $event.target.value)"
aria-label="{{ $label }} color"
class="absolute inset-0 h-full w-full cursor-pointer opacity-0" />
</label>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-black dark:text-fg">{{ $label }}</div>
<div class="truncate text-xs text-neutral-500 dark:text-fg-dim">{{ $description }}</div>
</div>
<input type="text" autocomplete="off" spellcheck="false" maxlength="7"
:value="draftColors.{{ $key }}"
@change="setDraftColor('{{ $key }}', $event.target.value); $event.target.value = draftColors.{{ $key }}"
aria-label="{{ $label }} hex color"
class="input w-28! shrink-0 font-mono text-sm" />
</div>
@endforeach
</div>
</div>
<div>
<label for="custom-theme-share-code"
class="mb-2 block text-xs font-medium text-neutral-500 dark:text-fg-dim">Share code</label>
<div class="flex gap-2">
<input id="custom-theme-share-code" x-ref="shareCode" type="text" autocomplete="off"
spellcheck="false" :value="shareCode"
@input="readShareCode($event.target.value)"
@blur="$event.target.value = shareCode; shareCodeInvalid = false"
:aria-invalid="shareCodeInvalid"
class="input min-w-0 flex-1 font-mono text-sm"
:class="shareCodeInvalid && 'border-error! ring-error!'" />
<button type="button" @click="copyShareCode()" aria-label="Copy share code"
class="button shrink-0 px-2.5">
<x-reicon name="copy" x-show="!shareCodeCopied" class="size-4" />
<x-reicon name="check" x-show="shareCodeCopied" x-cloak class="size-4 text-success" />
</button>
</div>
<p class="mt-1.5 text-xs"
:class="shareCodeInvalid ? 'text-error' : 'text-neutral-500 dark:text-fg-dim'"
x-text="shareCodeInvalid
? 'Enter four hex colors, separated by commas.'
: 'Paste a code that someone shared, or copy yours.'"></p>
</div>
</div>
</div>
</template>
<footer
class="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-neutral-200 px-4 py-3 dark:border-white/[0.08]">
<x-forms.button @click="dialogOpen = false">Cancel</x-forms.button>
<x-forms.button isHighlighted @click="saveCustomTheme()">Save theme</x-forms.button>
</footer>
</div>
</div>
</div>
</template>
</div>
@@ -1,50 +0,0 @@
@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>
{{-- autocomplete="off": Firefox session restore otherwise replays this
value with an input event and switches the theme to custom (#11813). --}}
<input type="color" autocomplete="off" :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>
@@ -0,0 +1,21 @@
@props(['theme'])
{{-- Small picture of a custom theme: sidebar with the selected row, page with a card.
Uses textOn() from the parent themeControls() scope. $theme is an Alpine expression. --}}
<div {{ $attributes->merge(['class' => 'flex overflow-hidden rounded-md border border-black/10 dark:border-white/10']) }}
aria-hidden="true">
<div class="flex w-[38%] flex-col gap-1 p-1.5" :style="`background: ${ {{ $theme }}.sidebar }`">
<span class="h-1 w-3/4 rounded-full opacity-50" :style="`background: ${textOn({{ $theme }}.sidebar)}`"></span>
<span class="h-2.5 w-full rounded-sm" :style="`background: ${ {{ $theme }}.selected }`"></span>
<span class="flex items-center gap-1">
<span class="h-1 w-1/2 rounded-full opacity-50" :style="`background: ${textOn({{ $theme }}.sidebar)}`"></span>
<span class="size-1.5 rounded-[2px]" :style="`background: ${ {{ $theme }}.accent }`"></span>
</span>
</div>
<div class="flex flex-1 flex-col gap-1 p-1.5" :style="`background: ${ {{ $theme }}.background }`">
<span class="h-1 w-2/3 rounded-full opacity-60" :style="`background: ${textOn({{ $theme }}.background)}`"></span>
<span class="h-1 w-1/2 rounded-full opacity-30" :style="`background: ${textOn({{ $theme }}.background)}`"></span>
<span class="mt-auto h-2.5 w-full rounded-sm border"
:style="`border-color: color-mix(in oklab, ${textOn({{ $theme }}.background)} 25%, transparent)`"></span>
</div>
</div>
+165 -70
View File
@@ -36,56 +36,183 @@
return luminance > 0.179 ? 'oklch(0% 0 0)' : 'oklch(100% 0 0)';
};
// Custom theme: four colors. Its share code is "#sidebar,#selected,#accent,#background".
// Every text color is derived from the color under it, so text stays readable.
const strictHex = (color) => {
const hex = String(color ?? '').trim().replace(/^#/, '').toLowerCase();
if (/^[a-f\d]{6}$/.test(hex)) return `#${hex}`;
if (/^[a-f\d]{3}$/.test(hex)) return `#${hex.replace(/./g, '$&$&')}`;
return null;
};
const relativeLuminance = (hex) => hex.match(/[a-f\d]{2}/gi)
.map((channel) => srgbToLinear(parseInt(channel, 16) / 255))
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
const mixHex = (from, to, amount) => `#${[1, 3, 5].map((offset) => {
const start = parseInt(from.slice(offset, offset + 2), 16);
const end = parseInt(to.slice(offset, offset + 2), 16);
return Math.round(start + (end - start) * amount).toString(16).padStart(2, '0');
}).join('')}`;
// True when light text has more contrast on this color than dark text (WCAG ratios).
window.isDarkColor = (hex) => {
const luminance = relativeLuminance(window.normalizeThemeColor(hex));
return 1.05 / (luminance + 0.05) >= (luminance + 0.05) / (relativeLuminance('#18181b') + 0.05);
};
window.readableTextOn = (hex) => window.isDarkColor(hex) ? '#f4f4f5' : '#18181b';
window.customThemeKeys = ['sidebar', 'selected', 'accent', 'background'];
window.serializeCustomTheme = (theme) => window.customThemeKeys.map((key) => theme[key]).join(',');
// A theme from a share code, or null when the text is not four hex colors.
window.parseCustomTheme = (code) => {
const colors = String(code ?? '').split(/[\s,]+/).filter(Boolean).map(strictHex);
if (colors.length !== 4 || colors.includes(null)) return null;
return Object.fromEntries(window.customThemeKeys.map((key, index) => [key, colors[index]]));
};
// The saved theme. Older versions saved one color and a light/dark mode; build the four colors from them.
window.storedCustomTheme = () => {
const saved = window.parseCustomTheme(localStorage.customTheme);
if (saved) return saved;
const accent = window.normalizeThemeColor(localStorage.themeColor);
return localStorage.customMode === 'light'
? { sidebar: mixHex('#f4f4f5', accent, 0.06), selected: mixHex('#ffffff', accent, 0.16), accent, background: mixHex('#fafafa', accent, 0.03) }
: { sidebar: mixHex('#0c0c0e', accent, 0.1), selected: mixHex('#1c1c1f', accent, 0.3), accent, background: mixHex('#0f0f11', accent, 0.12) };
};
window.customThemePresets = [
// The built-in Dark theme, not a custom theme. Its colors start the fields when the user edits it.
{ name: 'Coolify', theme: null, swatch: { sidebar: '#0d0d0d', selected: '#1c1c1c', accent: '#6b16ed', background: '#0a0a0a' } },
{ name: 'Midnight', theme: { sidebar: '#0f172a', selected: '#2e4a7d', accent: '#f2458f', background: '#1a2338' } },
{ name: 'Forest', theme: { sidebar: '#12342b', selected: '#2c6e57', accent: '#f4b860', background: '#1b2a25' } },
{ name: 'Ember', theme: { sidebar: '#3b1410', selected: '#8f2d1f', accent: '#ffb86b', background: '#241816' } },
{ name: 'Slate', theme: { sidebar: '#2b3038', selected: '#4a5361', accent: '#6cc4f5', background: '#353b45' } },
{ name: 'Nord', theme: { sidebar: '#2e3440', selected: '#4c566a', accent: '#88c0d0', background: '#3b4252' } },
{ name: 'Dracula', theme: { sidebar: '#21222c', selected: '#44475a', accent: '#bd93f9', background: '#282a36' } },
{ name: 'GitHub Dark', theme: { sidebar: '#010409', selected: '#21262d', accent: '#2f81f7', background: '#0d1117' } },
{ name: 'Aubergine', theme: { sidebar: '#3f0e40', selected: '#1164a3', accent: '#1164a3', background: '#ffffff' } },
{ name: 'GitHub Light', theme: { sidebar: '#f6f8fa', selected: '#dde4ec', accent: '#0969da', background: '#ffffff' } },
{ name: 'Paper', theme: { sidebar: '#ebe6da', selected: '#d9d1bf', accent: '#c2410c', background: '#f7f4ec' } },
{ name: 'Blossom', theme: { sidebar: '#fde8f1', selected: '#f9c4dc', accent: '#d61f69', background: '#fff7fa' } },
];
// Sets the variables that app.css derives the custom theme from. The sidebar gets its own
// light/dark scheme (data-sidebar-scheme) when it differs from the page.
window.applyCustomTheme = (theme) => {
const root = document.documentElement;
const isDark = window.isDarkColor(theme.background);
const sidebarIsDark = window.isDarkColor(theme.sidebar);
Object.entries({
'--theme-bg': theme.background,
'--theme-text': window.readableTextOn(theme.background),
'--theme-light': isDark ? '0' : '1',
'--theme-sidebar': theme.sidebar,
'--theme-sidebar-text': window.readableTextOn(theme.sidebar),
'--theme-sidebar-light': sidebarIsDark ? '0' : '1',
'--theme-selected': theme.selected,
'--theme-selected-text': window.readableTextOn(theme.selected),
'--theme-base-color': window.hexToOklch(theme.accent),
'--theme-accent-foreground': window.themeAccentForeground(theme.accent),
}).forEach(([name, value]) => root.style.setProperty(name, value));
root.dataset.theme = 'custom';
root.classList.toggle('dark', isDark);
if (sidebarIsDark === isDark) {
delete root.dataset.sidebarScheme;
} else {
root.dataset.sidebarScheme = sidebarIsDark ? 'dark' : 'light';
}
document.querySelector('meta[name=theme-color]')?.setAttribute('content', theme.background);
};
window.applyStoredTheme = () => {
const root = document.documentElement;
const theme = localStorage.theme === 'purple' ? 'custom' : (localStorage.theme || 'dark');
const themeColor = window.normalizeThemeColor(localStorage.themeColor);
const customMode = localStorage.customMode || 'dark';
document.documentElement.dataset.navStyle = localStorage.navStyle === 'floating' ? 'floating' : 'default';
document.documentElement.dataset.navItems = localStorage.navItems === 'flat' ? 'flat' : '3d';
const isDark = theme === 'dark'
|| (theme === 'custom' && customMode === 'dark')
|| (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
root.dataset.navStyle = localStorage.navStyle === 'floating' ? 'floating' : 'default';
root.dataset.navItems = localStorage.navItems === 'flat' ? 'flat' : '3d';
localStorage.theme = theme;
if (localStorage.themeColor) localStorage.themeColor = themeColor;
document.documentElement.classList.toggle('dark', isDark);
document.documentElement.dataset.theme = theme === 'custom' ? 'custom' : (isDark ? 'dark' : 'light');
document.documentElement.style.setProperty('--theme-base-color', window.hexToOklch(themeColor));
document.documentElement.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(themeColor));
if (localStorage.themeColor) localStorage.themeColor = window.normalizeThemeColor(localStorage.themeColor);
if (theme === 'custom') {
window.applyCustomTheme(window.storedCustomTheme());
return;
}
const isDark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
root.classList.toggle('dark', isDark);
root.dataset.theme = isDark ? 'dark' : 'light';
delete root.dataset.sidebarScheme;
root.style.setProperty('--theme-base-color', window.hexToOklch(localStorage.themeColor));
root.style.setProperty('--theme-accent-foreground', window.themeAccentForeground(localStorage.themeColor));
document.querySelector('meta[name=theme-color]')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
};
// Alpine state for the theme controls, used by the
// Appearance page via x-data="themeControls()".
// Alpine state for the theme controls on the Appearance page (x-data="themeControls()").
window.themeControls = () => ({
theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'),
themeColor: window.normalizeThemeColor(localStorage.getItem('themeColor')),
customMode: localStorage.getItem('customMode') || 'dark',
customTheme: window.storedCustomTheme(),
pageWidth: localStorage.getItem('pageWidth') || 'centered',
navStyle: localStorage.getItem('navStyle') === 'floating' ? 'floating' : 'default',
navItems: localStorage.getItem('navItems') === 'flat' ? 'flat' : '3d',
themeColorFrame: null,
pickerOpen: false,
init() {
localStorage.setItem('theme', this.theme);
this.applyTheme();
},
chooseCustom() {
this.setTheme('custom');
this.pickerOpen = !this.pickerOpen;
presets: window.customThemePresets,
// The custom theme dialog edits a draft; Save applies it. A null draft is the built-in Dark theme.
dialogOpen: false,
draft: null,
get draftColors() {
return this.draft ?? window.customThemePresets[0].swatch;
},
shareCode: '',
shareCodeInvalid: false,
shareCodeCopied: false,
setTheme(type) {
this.theme = type;
localStorage.setItem('theme', type);
this.applyTheme();
window.applyStoredTheme();
},
setCustomMode(mode) {
this.customMode = mode;
localStorage.setItem('customMode', mode);
if (this.theme !== 'custom') {
this.setTheme('custom');
openCustomDialog() {
this.setDraft(this.theme === 'custom' ? { ...this.customTheme } : null);
this.dialogOpen = true;
},
setDraft(theme) {
this.draft = theme;
this.shareCode = window.serializeCustomTheme(this.draftColors);
this.shareCodeInvalid = false;
},
setDraftColor(key, value) {
const theme = window.parseCustomTheme(window.serializeCustomTheme({ ...this.draftColors, [key]: value }));
if (theme) {
this.setDraft(theme);
}
},
readShareCode(code) {
const theme = window.parseCustomTheme(code);
if (theme) {
this.setDraft(theme);
return;
}
this.applyTheme();
this.shareCodeInvalid = code.trim() !== '';
},
async copyShareCode() {
try {
await navigator.clipboard.writeText(this.shareCode);
this.shareCodeCopied = true;
setTimeout(() => this.shareCodeCopied = false, 1500);
} catch {
this.$refs.shareCode?.select();
}
},
isDraftPreset(preset) {
return preset.theme === null || this.draft === null
? preset.theme === this.draft
: window.serializeCustomTheme(preset.theme) === window.serializeCustomTheme(this.draft);
},
textOn(color) {
return window.readableTextOn(color);
},
accentTextOn(color) {
return window.themeAccentForeground(color);
},
saveCustomTheme() {
this.dialogOpen = false;
if (this.draft === null) {
localStorage.removeItem('customTheme');
this.setTheme('dark');
return;
}
this.customTheme = { ...this.draft };
localStorage.setItem('customTheme', window.serializeCustomTheme(this.customTheme));
localStorage.setItem('themeColor', this.customTheme.accent);
this.setTheme('custom');
},
setWidth(width) {
this.pageWidth = width;
@@ -102,39 +229,6 @@
localStorage.setItem('navItems', style);
document.documentElement.dataset.navItems = style;
},
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);
@@ -189,9 +283,10 @@
@vite(['resources/js/app.js', 'resources/css/app.css'])
<script>
// Update theme-color meta tag (non-critical, can run async)
const t = localStorage.theme || 'dark';
const isDark = t === 'dark' || t === 'custom' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.getElementById('theme-color-meta')?.setAttribute('content', isDark ? '#101010' : '#ffffff');
const isDark = document.documentElement.classList.contains('dark');
document.getElementById('theme-color-meta')?.setAttribute('content', document.documentElement.dataset.theme === 'custom'
? window.storedCustomTheme().background
: (isDark ? '#101010' : '#ffffff'));
</script>
<style>
[x-cloak] {
@@ -292,7 +387,7 @@
if (theme == 'system') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
if (theme == 'dark' || theme == 'custom') {
if (theme == 'dark' || (theme == 'custom' && document.documentElement.classList.contains('dark'))) {
cpuColor = '#1e90ff'
ramColor = '#00ced1'
textColor = '#ffffff'
+121
View File
@@ -0,0 +1,121 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
seedBrowserResourceStack();
});
function customThemeState(mixed $page): array
{
return $page->script(<<<'JS'
() => ({
theme: localStorage.getItem('theme'),
code: localStorage.getItem('customTheme'),
themeColor: localStorage.getItem('themeColor'),
dataTheme: document.documentElement.dataset.theme,
dark: document.documentElement.classList.contains('dark'),
sidebarScheme: document.documentElement.dataset.sidebarScheme ?? null,
background: document.documentElement.style.getPropertyValue('--theme-bg'),
})
JS);
}
it('saves a preset from the custom theme dialog and keeps it after a reload', function () {
loginAndSkipBoarding();
$page = visit('/profile/appearance')->assertSee('Color theme');
$page->click('Custom')->assertSee('Presets');
$page->click('Aubergine')->click('Save theme');
$expected = [
'theme' => 'custom',
'code' => '#3f0e40,#1164a3,#1164a3,#ffffff',
'themeColor' => '#1164a3',
'dataTheme' => 'custom',
'dark' => false,
'sidebarScheme' => 'dark',
'background' => '#ffffff',
];
expect(customThemeState($page))->toBe($expected);
$page = $page->navigate('/')->assertSee('Dashboard')->assertNoJavaScriptErrors();
$page->screenshot(filename: 'custom-theme-aubergine');
expect(customThemeState($page))->toBe($expected);
});
it('reads a pasted share code and rejects an invalid one', function () {
loginAndSkipBoarding();
$page = visit('/profile/appearance')->assertSee('Color theme');
$page->click('Custom')->assertSee('Share code');
$page->type('#custom-theme-share-code', '#123,#456')
->assertSee('Enter four hex colors, separated by commas.');
$page->script(<<<'JS'
() => {
const input = document.getElementById('custom-theme-share-code');
input.value = '#0f172a, #2e4a7d, #f2458f, #1a2338';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
JS);
$page->assertDontSee('Enter four hex colors, separated by commas.')->click('Save theme');
expect(customThemeState($page))->toMatchArray([
'code' => '#0f172a,#2e4a7d,#f2458f,#1a2338',
'dark' => true,
'sidebarScheme' => null,
]);
});
it('converts an older one-color custom theme to four colors', function () {
loginAndSkipBoarding();
$page = visit('/')->assertSee('Dashboard');
$page->script(<<<'JS'
() => {
localStorage.removeItem('customTheme');
localStorage.setItem('theme', 'custom');
localStorage.setItem('themeColor', '#16a34a');
localStorage.setItem('customMode', 'light');
}
JS);
$page = $page->navigate('/')->assertSee('Dashboard')->assertNoJavaScriptErrors();
expect(customThemeState($page))->toMatchArray([
'dataTheme' => 'custom',
'dark' => false,
'background' => '#f3f7f5',
])
->and($page->script('() => window.serializeCustomTheme(window.storedCustomTheme())'))
->toBe('#e7efeb,#daf0e2,#16a34a,#f3f7f5');
});
it('switches back to the built-in Dark theme with the Coolify preset', function () {
loginAndSkipBoarding();
$page = visit('/profile/appearance')->assertSee('Color theme');
$page->script(<<<'JS'
() => {
localStorage.setItem('customTheme', '#0f172a,#2e4a7d,#f2458f,#1a2338');
localStorage.setItem('theme', 'custom');
}
JS);
$page = $page->navigate('/profile/appearance')->assertSee('Color theme');
$page->click('Custom')->assertSee('Presets');
$page->click('Coolify')->click('Save theme');
expect(customThemeState($page))->toMatchArray([
'theme' => 'dark',
'code' => null,
'dataTheme' => 'dark',
'dark' => true,
'sidebarScheme' => null,
]);
});