From 582399c3825acd8ba910ddf058e8ef119db2ab53 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:07:21 +0200 Subject: [PATCH] feat(ui): add four-color custom theme dialog with presets and share code --- DESIGN.md | 20 +- resources/css/app.css | 200 +++++++++------ .../views/components/theme-controls.blade.php | 216 +++++++++++++--- .../theme-controls/picker.blade.php | 50 ---- .../theme-controls/swatch.blade.php | 21 ++ resources/views/layouts/base.blade.php | 235 ++++++++++++------ tests/v4/Browser/CustomThemeTest.php | 121 +++++++++ 7 files changed, 625 insertions(+), 238 deletions(-) delete mode 100644 resources/views/components/theme-controls/picker.blade.php create mode 100644 resources/views/components/theme-controls/swatch.blade.php create mode 100644 tests/v4/Browser/CustomThemeTest.php diff --git a/DESIGN.md b/DESIGN.md index bb968934b5..1664c5866e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/resources/css/app.css b/resources/css/app.css index 332428ae58..38849b6e79 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -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 ). 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; diff --git a/resources/views/components/theme-controls.blade.php b/resources/views/components/theme-controls.blade.php index d72e1b25dd..4db687012e 100644 --- a/resources/views/components/theme-controls.blade.php +++ b/resources/views/components/theme-controls.blade.php @@ -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). --}} -
-
-
-
-
-
-
-
+
+
+ +
+
+
+ {{ $option['label'] }} +
-
-
- {{ $option['label'] }} - -
-

- {{ $option['description'] }} -

-
+

+ {{ $option['description'] }} +

-
@else
@@ -64,7 +55,7 @@ {{ $option['label'] }} -

@@ -92,7 +83,7 @@ - -

-
diff --git a/resources/views/components/theme-controls/swatch.blade.php b/resources/views/components/theme-controls/swatch.blade.php new file mode 100644 index 0000000000..ab7644f1c9 --- /dev/null +++ b/resources/views/components/theme-controls/swatch.blade.php @@ -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. --}} +
merge(['class' => 'flex overflow-hidden rounded-md border border-black/10 dark:border-white/10']) }} + aria-hidden="true"> +
+ + + + + + +
+
+ + + +
+
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 6d90cf784a..6dd415b03c 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -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'])