лендинг: 32 нарушения контраста WCAG AA — затемнены токены, поставлен сторож #3277
3 changed files with 201 additions and 23 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue