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);
},