diff --git a/resources/css/app.css b/resources/css/app.css index d5e507383d..4b0fef04e9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1440,21 +1440,24 @@ html[data-theme="custom"] { --theme-base-color: oklch(49.65% 0.2709 289.33); --theme-bright-color: color-mix(in srgb, var(--theme-base-color) 85%, oklch(100% 0 0)); --theme-scrollbar-thumb: color-mix(in srgb, var(--theme-bright-color) 70%, var(--theme-accent-foreground)); - /* 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); + /* Accent text and icons on the page. applyCustomTheme() computes a color with + 4.5:1 contrast on the page surfaces. */ + --theme-fg-on-surface: var(--theme-accent-text, var(--theme-bright-color)); + /* The opposite of the text on accent fills. Shades of a fill that carry that + text move toward it, so the text never has less contrast than on the fill. */ + --theme-accent-away: oklch(from var(--theme-accent-foreground) calc(1 - l) 0 0); --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)); - --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(0% 0 0)); + --color-coollabs-100: var(--theme-bright-color); + --color-coollabs-200: color-mix(in oklab, var(--theme-bright-color) 88%, var(--theme-accent-away)); --color-coollabs-300: color-mix(in oklab, var(--theme-bright-color) 72%, oklch(0% 0 0)); /* Legacy dark-theme accent utilities use warning as the brand color. */ --color-warning: var(--theme-bright-color); /* Brand fills, tints, and accent text follow the picked color. Text on a brand fill uses the computed foreground, so light colors stay readable. */ --color-brand: var(--theme-bright-color); - --color-brand-hover: color-mix(in oklab, var(--theme-bright-color) 88%, oklch(100% 0 0)); - --color-brand-press: color-mix(in oklab, var(--theme-bright-color) 85%, oklch(0% 0 0)); + --color-brand-hover: color-mix(in oklab, var(--theme-bright-color) 92%, var(--theme-accent-away)); + --color-brand-press: color-mix(in oklab, var(--theme-bright-color) 85%, var(--theme-accent-away)); --color-brand-depth: color-mix(in oklab, var(--theme-bright-color) 65%, oklch(0% 0 0)); --color-brand-ring: color-mix(in oklab, var(--theme-bright-color) 75%, oklch(100% 0 0)); --color-brand-foreground: var(--theme-accent-foreground); @@ -1465,11 +1468,13 @@ html[data-theme="custom"] { 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); } +/* The sidebar has its own accent text color. --color-brand-light is resolved on + , so set it again here. */ html[data-theme="custom"] :is(.app-sidebar, .app-topbar-brand, .mobile-sidebar-sheet [role="dialog"]) { + --theme-fg-on-surface: var(--theme-sidebar-accent-text, var(--theme-bright-color)); + --color-brand-light: var(--theme-fg-on-surface); --t-bg: var(--theme-sidebar); --t-text: var(--theme-sidebar-text); --t-light: var(--theme-sidebar-light); @@ -1960,8 +1965,10 @@ html[data-theme="custom"] textarea:disabled { --card-fg-faint: var(--color-fg-faint); } +/* The light well uses the theme surface, like the bg-white remap, instead of white. */ html[data-theme="custom"]:not(.dark) :is(.application-settings-section, .application-settings-section-body, .empty-state) { --card-underlay: var(--coollabs-canvas); + --well-bg: var(--coollabs-elevated); } /* Top-level cards and wells sit on the page: paint the translucent tint over diff --git a/resources/views/components/theme-controls.blade.php b/resources/views/components/theme-controls.blade.php index d38ce756d7..7ed7148bbd 100644 --- a/resources/views/components/theme-controls.blade.php +++ b/resources/views/components/theme-controls.blade.php @@ -310,7 +310,7 @@
Servers 3 + :style="`background: ${accentFill(draftColors.accent)}; color: ${accentTextOn(draftColors.accent)}`">3
Projects + New + :style="`background: ${accentFill(draftColors.accent)}; color: ${accentTextOn(draftColors.accent)}`">+ New
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index acd09ca28f..e3aaccbba0 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -14,28 +14,39 @@ if (/^[a-f\d]{3}$/.test(hex)) return `#${hex.replace(/./g, '$&$&')}`; return '#6b16ed'; }; - window.hexToOklch = (hex) => { + const hexToOklab = (hex) => { const [r, g, b] = window.normalizeThemeColor(hex).match(/[a-f\d]{2}/gi).map((channel) => srgbToLinear(parseInt(channel, 16) / 255)); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); - const okL = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s; - const okA = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s; - const okB = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s; + return [ + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ]; + }; + // OKLab -> sRGB hex. Out-of-gamut channels are clipped. + const oklabToHex = (okL, okA, okB) => { + const l = (okL + 0.3963377774 * okA + 0.2158037573 * okB) ** 3; + const m = (okL - 0.1055613458 * okA - 0.0638541728 * okB) ** 3; + const s = (okL - 0.0894841775 * okA - 1.2914855480 * okB) ** 3; + return `#${[ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + ].map((c) => { + const linear = Math.min(1, Math.max(0, c)); + const encoded = linear <= 0.0031308 ? 12.92 * linear : 1.055 * linear ** (1 / 2.4) - 0.055; + return Math.round(encoded * 255).toString(16).padStart(2, '0'); + }).join('')}`; + }; + window.hexToOklch = (hex) => { + const [okL, okA, okB] = hexToOklab(hex); const chroma = Math.sqrt(okA * okA + okB * okB); let hue = Math.atan2(okB, okA) * 180 / Math.PI; if (hue < 0) hue += 360; return `oklch(${(okL * 100).toFixed(2)}% ${chroma.toFixed(4)} ${hue.toFixed(2)})`; }; - window.themeAccentForeground = (color) => { - const channels = window.normalizeThemeColor(color).match(/[a-f\d]{2}/gi).map(channel => parseInt(channel, 16) * 0.85 + 255 * 0.15); - const luminance = channels - .map(channel => channel / 255) - .map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4) - .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0); - - return luminance > 0.179 ? '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) => { @@ -52,12 +63,40 @@ const end = parseInt(to.slice(offset, offset + 2), 16); return Math.round(start + (end - start) * amount).toString(16).padStart(2, '0'); }).join('')}`; + // The color of accent fills: the accent with 15% white, like --theme-bright-color in app.css. + window.themeAccentFill = (color) => mixHex(window.normalizeThemeColor(color), '#ffffff', 0.15); + // Black or white, whichever has more contrast on the accent fill (WCAG ratios). + window.themeAccentForeground = (color) => { + const luminance = relativeLuminance(window.themeAccentFill(color)); + return 1.05 / (luminance + 0.05) > (luminance + 0.05) / 0.05 ? 'oklch(100% 0 0)' : 'oklch(0% 0 0)'; + }; // 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'; + const contrast = (first, second) => { + const [dark, light] = [relativeLuminance(first), relativeLuminance(second)].sort((a, b) => a - b); + return (light + 0.05) / (dark + 0.05); + }; + // Accent text and icons on a surface: the accent, moved toward white on a dark + // surface or toward black on a light one until it reaches 4.5:1 (WCAG AA). It is + // checked on the surface with 12% of its text color, the strongest tint that the + // surface ladder in app.css puts under text (fills, selected rows). + window.themeAccentTextOn = (accent, surface) => { + const [okL, okA, okB] = hexToOklab(accent); + const chromaScale = Math.min(1, 0.16 / (Math.hypot(okA, okB) || 1)); + const target = window.isDarkColor(surface) ? 1 : 0; + const tintedSurface = mixHex(window.normalizeThemeColor(surface), window.readableTextOn(surface), 0.12); + let color; + for (let step = 0; step <= 50; step++) { + const fade = chromaScale * (1 - step / 50); + color = oklabToHex(okL + (target - okL) * step / 50, okA * fade, okB * fade); + if (contrast(color, tintedSurface) >= 4.5) break; + } + return color; + }; 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. @@ -108,6 +147,8 @@ '--theme-selected-text': window.readableTextOn(theme.selected), '--theme-base-color': window.hexToOklch(theme.accent), '--theme-accent-foreground': window.themeAccentForeground(theme.accent), + '--theme-accent-text': window.themeAccentTextOn(theme.accent, theme.background), + '--theme-sidebar-accent-text': window.themeAccentTextOn(theme.accent, theme.sidebar), }).forEach(([name, value]) => root.style.setProperty(name, value)); root.dataset.theme = 'custom'; root.classList.toggle('dark', isDark); @@ -267,6 +308,9 @@ textOn(color) { return window.readableTextOn(color); }, + accentFill(color) { + return window.themeAccentFill(color); + }, accentTextOn(color) { return window.themeAccentForeground(color); },