fix(theme): readable text on any operator palette + admin consistency pass

Контраст-аудит (WCAG-обход всех текстовых узлов, 4 палитры × юзер+админ
страницы) показал системные провалы читаемости: вторичный текст 1.5-2.5
на светлой/кастомных темах, статусные цвета 1.3-1.6, белый текст на
светлых акцентах 1.9.

Корневые фиксы:
- useThemeColors: контраст-кламп производных серых токенов — dark-400/
  champagne-600 (вторичный текст) держат >=5.0 к поверхности, dark-500/
  champagne-500 (подсказки) >=3.8; палитры с достаточным контрастом
  рендерятся байт-в-байт как раньше
- --color-on-accent/-success/-warning/-error: чёрный или белый текст
  по фактической яркости цвета оператора; text-white на accent-заливках
  заменён на text-on-accent (135 мест + variant примитива Button +
  .btn-primary)
- .light: статусные текстовые оттенки *-300/400 ремапятся на тёмные
  *-700 (жёлтая «Админка» 1.26 -> 3.8+, зелёные суммы 1.4 -> 3.4+,
  бейджи состояний)

Замер после: операторская тёмная палитра — 0 нарушений (всё >=4.5),
худшие точки остальных подняты с 1.26-1.6 до 3.2+.

Консистентность:
- заголовки админ-страниц приведены к text-xl font-bold text-dark-100
  (были 18/20/24/30px и 600/700 вперемешку)
- «Создать FAQ»: warning-500/80+белый -> warning-500+text-on-warning
- formatTraffic: единицы из i18n (убран микс «GB»/«ГБ» на одном экране)
- categoryCanvas -> categoryCANVAS в локалях (битый ключ на настройках)
- «Управление сквадами» -> «Управление локациями» (юзерский экран)
- мин-макс сумм на выборе метода: dark-600 -> dark-400
- неактивные пункты нижнего нава: dark-500 -> dark-400
This commit is contained in:
c0mrade
2026-06-11 15:49:17 +03:00
parent 47119c4c1f
commit 7f68e2cd5e
91 changed files with 287 additions and 176 deletions

View File

@@ -54,6 +54,61 @@ function interpolateRgb(
);
}
type Rgb = { r: number; g: number; b: number };
function mixRgb(rgb1: Rgb, rgb2: Rgb, factor: number): Rgb {
return {
r: Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
g: Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
b: Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor),
};
}
// WCAG relative luminance
function relativeLuminance({ r, g, b }: Rgb): number {
const srgb = (v: number) => {
const c = v / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
}
// WCAG contrast ratio between two colors
function contrastRatio(a: Rgb, b: Rgb): number {
const l1 = relativeLuminance(a);
const l2 = relativeLuminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/**
* Guarantee a minimum contrast for a TEXT color against its background.
*
* Operators pick arbitrary palette colors in the theme editor, and several
* tokens are additionally blended toward the surface (dark-500 = secondary
* text mixed 40% into the card color). Without a floor, hint/meta text
* regularly lands at a 1.5-2.5 contrast ratio and becomes unreadable.
* When the color already passes, it is returned untouched, so well-tuned
* palettes render byte-for-byte the same as before.
*/
function ensureReadable(fg: Rgb, towards: Rgb, bg: Rgb, minRatio: number): Rgb {
if (contrastRatio(fg, bg) >= minRatio) return fg;
for (let t = 0.1; t <= 1; t += 0.1) {
const mixed = mixRgb(fg, towards, t);
if (contrastRatio(mixed, bg) >= minRatio) return mixed;
}
return towards;
}
// Black-or-white text for a given button/badge background: pick whichever
// side actually reads on top of it (operators may choose a light accent).
function onColorFor(bgTriplet: string): string {
const [r, g, b] = bgTriplet.split(',').map((x) => Number(x.trim()));
const bg = { r, g, b };
const white = { r: 255, g: 255, b: 255 };
const ink = { r: 15, g: 23, b: 42 };
return contrastRatio(white, bg) >= contrastRatio(ink, bg) ? '255, 255, 255' : '15, 23, 42';
}
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
export function applyThemeColors(colors: ThemeColors): void {
const root = document.documentElement;
@@ -70,6 +125,17 @@ export function applyThemeColors(colors: ThemeColors): void {
const darkTextRgb = hexToRgb(colors.darkText);
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary);
// Contrast floors: secondary text must stay readable on the card surface
// regardless of the operator-chosen palette (AA 4.5 for dark-400, a softer
// 3.5 floor for the blended hint token dark-500).
const darkTextSecReadable = ensureReadable(darkTextSecRgb, darkTextRgb, darkSurfaceRgb, 5.0);
const darkHintReadable = ensureReadable(
mixRgb(darkTextSecRgb, darkSurfaceRgb, 0.4),
darkTextRgb,
darkSurfaceRgb,
3.8,
);
// Apply dark palette with actual user colors:
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
root.style.setProperty(
@@ -84,11 +150,14 @@ export function applyThemeColors(colors: ThemeColors): void {
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66));
root.style.setProperty(
'--color-dark-400',
rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b),
rgbToString(darkTextSecReadable.r, darkTextSecReadable.g, darkTextSecReadable.b),
);
// Transition colors (500-700): interpolate between secondary text and surface
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4));
root.style.setProperty(
'--color-dark-500',
rgbToString(darkHintReadable.r, darkHintReadable.g, darkHintReadable.b),
);
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6));
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8));
@@ -122,11 +191,23 @@ export function applyThemeColors(colors: ThemeColors): void {
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2));
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4));
// Transition colors (500-600): between bg and text
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6));
// Transition colors (500-600): between bg and text.
// Same contrast floors as the dark palette: champagne-600 backs dark-400
// (secondary text) in the light theme, champagne-500 backs dark-500 (hints).
const lightHintReadable = ensureReadable(
mixRgb(lightBgRgb, lightTextSecRgb, 0.6),
lightTextRgb,
lightSurfaceRgb,
3.8,
);
const lightTextSecReadable = ensureReadable(lightTextSecRgb, lightTextRgb, lightSurfaceRgb, 5.0);
root.style.setProperty(
'--color-champagne-500',
rgbToString(lightHintReadable.r, lightHintReadable.g, lightHintReadable.b),
);
root.style.setProperty(
'--color-champagne-600',
rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b),
rgbToString(lightTextSecReadable.r, lightTextSecReadable.g, lightTextSecReadable.b),
);
// Text colors (700-950): secondary to primary text
@@ -154,6 +235,13 @@ export function applyThemeColors(colors: ThemeColors): void {
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]);
}
// Readable text color on top of each status color (buttons, filled badges).
// Hardcoded white breaks the moment an operator picks a light accent.
root.style.setProperty('--color-on-accent', onColorFor(accentPalette[500]));
root.style.setProperty('--color-on-success', onColorFor(successPalette[500]));
root.style.setProperty('--color-on-warning', onColorFor(warningPalette[500]));
root.style.setProperty('--color-on-error', onColorFor(errorPalette[500]));
// Apply semantic colors (hex for direct use)
root.style.setProperty('--color-dark-bg', colors.darkBackground);
root.style.setProperty('--color-dark-surface', colors.darkSurface);