лендинг: 32 нарушения контраста WCAG AA — затемнены токены, поставлен сторож #3277

Merged
bot-backend merged 2 commits from fix/mera-public-contrast into main 2026-08-30 10:15:15 +00:00
3 changed files with 201 additions and 23 deletions

View file

@ -0,0 +1,152 @@
/**
* contrast-tokens контраст WCAG 2.1 AA прямо на ЗНАЧЕНИЯХ токенов `b2c`,
* без браузера и без axe.
*
* Зачем. 30.08.2026 axe нашёл на публичном лэндинге 32 узла `color-contrast`
* (все severity=serious), и все они следствие одного и того же: пара
* «цвет × фон» подбиралась глазами, а число в комментарии писалось по памяти.
* Шапка `b2c-tokens.ts` утверждала «onAccent на accentDeep 4.6:1», реальный
* замер 4.41, то есть главная кнопка призыва не проходила AA, а
* документация уверяла, что проходит. Браузерный аудит ловит это раз в
* квартал; здесь то же самое считается на каждом прогоне тестов.
*
* Проверяются ОБА фона страницы: светлый (`pageBg`/`surface`/`surfaceSoft`) и
* тёмная плашка (`surfaceDark`). Половина из 32 нарушений была именно светлым
* токеном, положенным на тёмную плашку, на ней шкала перевёрнута.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { b2c } from "../b2c-tokens";
/** Относительная яркость по WCAG 2.1 (§ relative luminance). */
function luminance(hex: string): number {
const h = hex.replace("#", "");
const channels = [0, 2, 4]
.map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return (
0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
);
}
/** Контраст пары цветов по WCAG 2.1 (§ contrast ratio). */
export function contrast(fg: string, bg: string): number {
const a = luminance(fg);
const b = luminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
/** Норма AA для обычного текста. Крупный (≥24px / ≥18.66px bold) — 3.0. */
const AA_NORMAL = 4.5;
/** Светлые фоны лэндинга. Худший из них — `surfaceSoft`, он темнее `pageBg`. */
const LIGHT_BACKGROUNDS: ReadonlyArray<readonly [string, string]> = [
["surface", b2c.surface],
["pageBg", b2c.pageBg],
["surfaceSoft", b2c.surfaceSoft],
];
describe("контраст токенов b2c", () => {
it("формула считает эталонные пары верно", () => {
// Реперы: чёрное на белом — 21:1, цвет сам на себе — 1:1.
expect(contrast("#000000", "#FFFFFF")).toBeCloseTo(21, 1);
expect(contrast("#0E8496", "#0E8496")).toBeCloseTo(1, 5);
// Прежнее значение accentDeep — то самое 4.41, из-за которого кнопка
// призыва попала в отчёт axe. Если формула сломается, репер покажет это.
expect(contrast("#FFFFFF", "#0E8496")).toBeCloseTo(4.41, 2);
});
it("текстовые токены проходят AA на всех светлых фонах", () => {
const textTokens: ReadonlyArray<readonly [string, string]> = [
["ink", b2c.ink],
["muted", b2c.muted],
["accentText", b2c.accentText],
["success", b2c.success],
["danger", b2c.danger],
];
for (const [fgName, fg] of textTokens) {
for (const [bgName, bg] of LIGHT_BACKGROUNDS) {
expect(
contrast(fg, bg),
`${fgName} на ${bgName}`,
).toBeGreaterThanOrEqual(AA_NORMAL);
}
}
});
it("белый текст на заливке кнопки проходит AA", () => {
// .heroSubmitBtn, .hdrCta, .gameBtnAccent — сплошная заливка accentDeep.
expect(contrast(b2c.onAccent, b2c.accentDeep)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
});
it("на тёмной плашке текст красится mutedDark/accent, а не muted/accentText", () => {
// Что ГОДИТСЯ на surfaceDark.
expect(contrast(b2c.mutedDark, b2c.surfaceDark)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
expect(contrast(b2c.accent, b2c.surfaceDark)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
expect(contrast(b2c.onAccent, b2c.surfaceDark)).toBeGreaterThanOrEqual(
AA_NORMAL,
);
// Что НЕ годится — это и были метки тикера (2.85 и 2.7). Ассерт держит
// правило «на тёмном берём светлый конец шкалы»: если кто-то осветлит
// muted до проходного на плашке, он сломает его же на светлом фоне, и
// предыдущий тест покраснеет.
expect(contrast(b2c.muted, b2c.surfaceDark)).toBeLessThan(AA_NORMAL);
expect(contrast(b2c.accentText, b2c.surfaceDark)).toBeLessThan(AA_NORMAL);
});
it("accent не используется как текст на светлом: он и не может", () => {
// Ради этого в палитре и живёт отдельный accentText. Ассерт фиксирует
// причину, чтобы «упростить до одного акцента» нельзя было молча.
expect(contrast(b2c.accent, b2c.surface)).toBeLessThan(AA_NORMAL);
});
/**
* Тест выше защищает ЗНАЧЕНИЯ токенов но не поймал бы `color: #9fb0b4`,
* вписанный в CSS мимо палитры. Именно так и появились .artSoon (2.24),
* .gameScale, .repPaidFine, .artFootnote: сырой hex не проходит ни через
* какой аудит, потому что его никто не считает токеном.
*
* ponytail: список разрешённых снимок, а не расчёт: по CSS нельзя узнать,
* на какой подложке лежит правило (#ffffff годится на плашке и провален на
* карточке). Когда фон станет выводим (напр. все тёмные секции получат
* общий класс), эту тройку можно заменить настоящим расчётом.
*/
it("в CSS лэндинга нет новых сырых hex-цветов текста", () => {
const css = readFileSync(
join(__dirname, "..", "landing-v3.module.css"),
"utf8",
);
const found = [
...css.matchAll(/(?:color|fill):\s*(#[0-9a-fA-F]{3,8})\b/g),
].map((m) => m[1].toLowerCase());
const allowed = new Set([
"#ffffff", // текст на surfaceDark-плашках, 17:1
"#3fd1a6", // .tickerGain на плашке, 9.1:1
"#3a4a4f", // .gameVerdict на светлой карточке, 9.2:1
]);
expect([...new Set(found)].filter((h) => !allowed.has(h))).toEqual([]);
// Сам сканер проверен: он обязан находить hex вообще.
expect(found.length).toBeGreaterThan(0);
});
it("декоративные линии различимы как границы (норма 3.0 не для них)", () => {
// Границы — не текст, AA к ним не применяется; проверяем лишь, что они
// вообще отличаются от своей подложки, иначе рамка невидима.
expect(contrast(b2c.line, b2c.surface)).toBeGreaterThan(1.2);
expect(contrast(b2c.lineDark, b2c.surfaceDark)).toBeGreaterThan(1.2);
});
});

View file

@ -22,10 +22,30 @@
* старом theme.ts было для #2e8bff);
* - `accentText` (#0B6B79) тот же оттенок, затемнённый до **5.8:1** на
* фоне страницы; им красится текст ссылок и акцентные подписи;
* - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0E8496) 4.6:1, годится
* - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0C7B8B) 4.97:1, годится
* для текста кнопок.
* Визуально разница между #16A9BC и #0B6B79 в тонкой ссылке незаметна, а
* читаемость меняется принципиально.
*
* ПРАВКА 30.08.2026 (axe: 32 узла `color-contrast`, все serious). Прежние
* значения считались «по ощущению», и четыре из них не добирали до 4.5 а
* подписанное в этой шапке «4.6:1» для `accentDeep` #0E8496 было просто
* неверным замером: реально **4.41**. Затемнены до запаса 4.8 на худшем
* светлом фоне (`surfaceSoft` #EAF5F7 он темнее `pageBg`):
* accentDeep #0E8496 #0C7B8B (белый текст 4.41 4.97)
* muted #667579 #5B686C (на pageBg 4.45 5.36)
* success #1B8A5A #177A50 (на pageBg 4.04 4.96)
* danger #C0442E #B33D28 (на surfaceSoft 4.60 5.23)
*
* ТЁМНАЯ ПЛАШКА (`surfaceDark` #121A1D) второй «фон-тема» этой страницы, и
* правила там ПЕРЕВЁРНУТЫ: `muted` там 2.7:1, `accentText` 2.85:1. На тёмном
* текст красится `mutedDark` (6.2:1) и `accent` (6.24:1), т.е. светлым концом
* шкалы. Отсюда: перед тем как взять токен для текста, смотри, на ЧЁМ он
* лежит половина из 32 нарушений была именно светлым токеном на тёмной
* плашке.
*
* Числа проверяются тестом `__tests__/contrast-tokens.test.ts` он считает
* яркость по формуле WCAG прямо на значениях отсюда, без браузера.
*/
export const b2c = {
@ -41,7 +61,7 @@ export const b2c = {
/** Основной текст. */
ink: "#121A1D",
/** Вторичный текст, подписи. */
muted: "#667579",
muted: "#5B686C",
/** Границы, разделители. */
line: "#CBD7DA",
@ -73,16 +93,16 @@ export const b2c = {
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
accent: "#16A9BC",
/** Акцент для заливки кнопок (под белым текстом). */
accentDeep: "#0E8496",
accentDeep: "#0C7B8B",
/** Акцент для ТЕКСТА — затемнён до AA. */
accentText: "#0B6B79",
/** Текст на акцентной заливке. */
onAccent: "#FFFFFF",
/** Положительная дельта («выше средней цены»). */
success: "#1B8A5A",
success: "#177A50",
/** Отрицательная дельта, предупреждения. */
danger: "#C0442E",
danger: "#B33D28",
font: {
sans: "var(--font-manrope), 'Manrope', system-ui, sans-serif",

View file

@ -635,8 +635,10 @@
}
.heroSubmitBtn {
/* accentDeep, не accent: белый текст на #16A9BC даёт ~2.8:1 (провал AA),
на accentDeep см. обоснование в b2c-tokens.ts. Затемнение на hover
/* accentDeep, не accent: белый текст на #16A9BC даёт 2.82:1 (провал AA), на
accentDeep 4.97:1 после затемнения токена 30.08 (до него было 4.41, и
axe считал главную кнопку призыва нарушением). См. b2c-tokens.ts.
Затемнение на hover
через filter, чтобы не заводить ещё один цветовой литерал (тот же приём,
что в .hdrCta выше). */
background: var(--b2c-accent-deep);
@ -1438,7 +1440,7 @@
align-items: center;
gap: 20px;
padding: 0 30px;
border-right: 1px solid #2c3a3f;
border-right: 1px solid var(--b2c-line-dark);
white-space: nowrap;
}
.tickerObj {
@ -1451,23 +1453,27 @@
align-items: center;
gap: 8px;
}
/* Лента лежит на `surface-dark`, поэтому светлые токены здесь МЕНЯЮТСЯ
МЕСТАМИ со светлой темой: `muted` даёт на плашке 2.7:1, `accent-text`
2.85:1 (замер axe 30.08: обе метки в списке из 32 нарушений). На тёмном
читаются `muted-dark` (6.2:1) и сам `accent` (6.24:1). */
.tickerLabelMuted {
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.12em;
color: var(--b2c-muted);
color: var(--b2c-muted-dark);
}
.tickerLabelAccent {
composes: tickerLabelMuted;
color: var(--b2c-accent-text);
color: var(--b2c-accent);
}
.tickerSaid {
font-size: 14px;
font-weight: 600;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.tickerArrow {
color: #2c3a3f;
color: var(--b2c-muted-dark);
font-size: 15px;
}
.tickerFact {
@ -1478,7 +1484,7 @@
.tickerErr {
font-family: var(--b2c-font-mono);
font-size: 11px;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.tickerGain {
display: flex;
@ -1654,7 +1660,7 @@
.gameMeta {
margin-top: 8px;
font-size: 14px;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.gameTags {
display: flex;
@ -1665,7 +1671,7 @@
.gameTag {
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: #9fb0b4;
color: var(--b2c-muted-dark);
border: 1px solid var(--b2c-line-dark);
border-radius: 4px;
padding: 5px 10px;
@ -1739,7 +1745,7 @@
margin-bottom: 24px;
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: #9fb0b4;
color: var(--b2c-muted);
}
.gameBtnAccent {
display: block;
@ -1809,7 +1815,7 @@
}
.gameRowLabelMuted {
composes: gameRowLabel;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.gameRowValue {
font-weight: 800;
@ -1936,7 +1942,7 @@
}
.pathBodyDark {
composes: pathBody;
color: #9fb0b4;
color: var(--b2c-muted-dark);
}
.pathBtnLight {
display: block;
@ -2368,7 +2374,7 @@
margin: 0;
font-size: 13px;
line-height: 1.55;
color: #9fb0b4;
color: var(--b2c-muted);
}
/* --- Раздел статей и хаб документов (макет 29.08) ------------------------- */
@ -2422,7 +2428,7 @@
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.16em;
color: #9fb0b4;
color: var(--b2c-muted);
}
.artToc {
margin-bottom: clamp(24px, 3vw, 32px);
@ -2463,7 +2469,7 @@
margin: 0 0 16px;
font-size: 16px;
line-height: 1.7;
color: #2c3a3f;
color: var(--b2c-line-dark);
text-wrap: pretty;
}
.artH2 {
@ -2565,7 +2571,7 @@
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.08em;
color: #9fb0b4;
color: var(--b2c-muted);
}
.artTiles {
display: grid;
@ -2614,7 +2620,7 @@
margin: 0 0 20px;
font-size: 15px;
line-height: 1.6;
color: #9fb0b4;
color: var(--b2c-muted-dark);
max-width: 520px;
}
.artCta .gameBtnAccent {