mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(theme): keep custom accent text and fills readable
Compute accent text per page and sidebar surface to reach 4.5:1, move fill shades away from their text, pick the fill text by exact WCAG contrast, show the real fill in the preview, and use the theme surface for light custom wells. Closes #12468 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5eee9933ca
commit
6518734935
+16
-9
@@ -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
|
||||
<html>, 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
|
||||
|
||||
@@ -310,7 +310,7 @@
|
||||
<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>
|
||||
:style="`background: ${accentFill(draftColors.accent)}; color: ${accentTextOn(draftColors.accent)}`">3</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2 p-3"
|
||||
@@ -318,7 +318,7 @@
|
||||
<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>
|
||||
:style="`background: ${accentFill(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})`">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user