#767676 daje 4,54:1 na białym i 4,16:1 na karcie. Paleta przechodzi, strona nie
Ten sam token tekstu przechodzi próg 4,5:1 na białym tle i nie na karcie #f5f5f5. Jak policzyć macierz par kontrastu z tokenów CSS przed oddaniem strony.
Szary #767676 na białym tle daje kontrast 4,54:1 i przechodzi próg 4,5:1. Ten sam token na karcie #f5f5f5 daje 4,16:1 i progu już nie przechodzi. W palecie, którą zwrócił model, nie ma w tym miejscu błędu — błąd jest w tym, że paleta opisuje kolory, a kryterium kontrastu dotyczy wyłącznie par kolorów. Komponent, który wziął token tekstu i położył go na drugiej powierzchni, wyprodukował nową parę, której nikt nie policzył.
To nie jest defekt marginalny. W badaniu z lutego 2025 r. WebAIM Million wykrył tekst o zbyt niskim kontraście na 79,1 proc. miliona stron głównych, średnio 29,6 odrębnych wystąpień na stronę — pierwsze miejsce na liście najczęstszych błędów, przed brakującym tekstem alternatywnym (55,5 proc.) i brakującymi etykietami pól formularza (48,2 proc.).
Wzór z 1.4.3 liczy pary, a wyniku nie wolno zaokrąglać
Kontrast w WCAG to iloraz (L1 + 0,05) / (L2 + 0,05), gdzie L to luminancja relatywna jaśniejszego i ciemniejszego koloru, liczona z kanałów sRGB po korekcie gamma, z wagami 0,2126 dla R, 0,7152 dla G i 0,0722 dla B. Wzór nie zna pojęcia „kolor marki” ani „paleta” — przyjmuje dwie wartości i zwraca jedną liczbę.
Progi zależą od roli, jaką kolor pełni w interfejsie, i od rozmiaru tekstu. Dokumentacja W3C do kryterium 1.4.3 Contrast (Minimum) definiuje tekst duży jako co najmniej 18 pt albo 14 pt pogrubiony, co przy przeliczniku 1 pt = 1,333 px daje około 24 px i 18,5 px — i wprost zastrzega, że wyniku nie zaokrągla się, więc 4,499:1 jest wartością odrzucającą, nie graniczną.
| Rola koloru | Próg | Kryterium |
|---|---|---|
| tekst poniżej 24 px (lub poniżej 18,5 px pogrubiony) | 4,5:1 | 1.4.3 AA |
| tekst od 24 px, pogrubiony od 18,5 px | 3:1 | 1.4.3 AA |
| obramowanie i wskaźnik stanu kontrolki | 3:1 | 1.4.11 AA |
| element graficzny niosący treść (ikona bez etykiety, linia wykresu) | 3:1 | 1.4.11 AA |
| kontrolka nieaktywna, logo, grafika dekoracyjna | bez wymogu | wyłączenia w 1.4.3 i 1.4.11 |
Dwa wiersze z końca tabeli to wyłączenia, nie luki do wykorzystania. Dokumentacja 1.4.3 zdejmuje wymóg z tekstu w nieaktywnej kontrolce i z nazwy w logotypie, ale nie z aktywnego przycisku, który tylko wygląda na wyszarzony. Jeśli element da się kliknąć, obowiązuje go próg.
Siedem tokenów to dwadzieścia kilka par, które naprawdę występują
Siedem zmiennych w :root wygląda na siedem decyzji. W renderowanej stronie liczy się coś innego: zbiór par tekst–powierzchnia, które faktycznie powstały. Powierzchni jest zwykle więcej, niż wynika z makiety — tło strony, karta, stopka, plakietka statusu, wiersz tabeli w stanie hover, modal, a przy włączonym trybie ciemnym cała druga rodzina. Każda powierzchnia mnoży liczbę par.
Pełna macierz N×N jest bezużyteczna, bo większość kombinacji nigdy nie powstanie. Sens ma lista par zadeklarowanych: dla każdego komponentu, który model wygenerował, jedna pozycja z rolą, tokenem tekstu, tokenem tła i progiem wynikającym z rozmiaru czcionki w tym komponencie. Taką listę buduje się raz, z inwentarza komponentów — przejściem po katalogu i wypisaniem, które var(--…) spotykają się w jednym bloku:
grep -rn --include=*.css -E 'var\(--(tekst|akcent)' src/ | cut -d: -f1 | sort -u
Para, której nie ma w tym pliku, nie jest sprawdzana — i to jest druga funkcja listy. Plik par wędruje razem z projektem jako dokument odbioru: pokazuje, co zostało zmierzone, przy jakich progach i czego świadomie nie mierzono. Obowiązują go te same zasady, co każdej procedurze przekazywanej dalej — opis, który ktoś inny ma wykonać bez autora obok, musi mieć jawne kryterium wyniku, a nie intencję.
Skrypt, który liczy macierz par i zwraca kod wyjścia 1
Poniższy skrypt czyta tokeny z pliku CSS, pary z pliku JSON i kończy się niezerowym kodem wyjścia, kiedy choć jedna para jest poniżej progu. To wystarcza, żeby wstawić go do pipeline’u obok lintera. Obcięcie wyniku przez Math.floor jest celowe: zaokrąglenie w górę przepuściłoby 4,497:1 jako 4,5:1.
import { readFileSync } from 'node:fs';
const kanal = (v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
function luminancja(hex) {
const h = hex.trim().replace('#', '');
const p = h.length === 3 ? h.split('').map((c) => c + c) : h.match(/../g);
const [r, g, b] = p.map((c) => kanal(parseInt(c, 16) / 255));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function kontrast(a, b) {
const [l1, l2] = [luminancja(a), luminancja(b)].sort((x, y) => y - x);
return (l1 + 0.05) / (l2 + 0.05);
}
const tokeny = Object.fromEntries(
[...readFileSync(process.argv[2], 'utf8')
.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{3,6})/g)]
.map((m) => [m[1], m[2]])
);
const pary = JSON.parse(readFileSync(process.argv[3], 'utf8'));
let bledy = 0;
for (const { rola, tekst, tlo, prog } of pary) {
const fg = tokeny[tekst], bg = tokeny[tlo];
if (!fg || !bg) { console.log(`BRAK TOKENU ${rola}`); bledy++; continue; }
const r = Math.floor(kontrast(fg, bg) * 100) / 100;
const ok = r >= prog;
if (!ok) bledy++;
console.log(`${ok ? 'OK ' : 'FAIL'} ${r.toFixed(2)}:1 (prog ${prog}:1) ${rola} ${fg} na ${bg}`);
}
console.log(`${pary.length} par, ${bledy} ponizej progu`);
process.exit(bledy ? 1 : 0);
Uruchomiony na palecie z otwarcia tego tekstu — siedem tokenów, siedem zadeklarowanych par — wypisuje:
OK 4.54:1 (prog 4.5:1) tekst ciagly na tle strony #767676 na #ffffff
FAIL 4.16:1 (prog 4.5:1) tekst ciagly na karcie #767676 na #f5f5f5
OK 14.75:1 (prog 3:1) naglowek h2 24px #1f2933 na #ffffff
OK 4.57:1 (prog 4.5:1) link w tresci #2f6feb na #ffffff
FAIL 4.19:1 (prog 4.5:1) link na karcie #2f6feb na #f5f5f5
FAIL 1.41:1 (prog 3:1) obramowanie inputa #d9d9d9 na #ffffff
OK 4.19:1 (prog 3:1) pierscien fokusu na karcie #2f6feb na #f5f5f5
7 par, 3 ponizej progu
Trzy odrzucenia na siedem par przy palecie, w której każdy pojedynczy kolor wyglądał rozsądnie. Dwa z nich to ta sama przyczyna: karta #f5f5f5 zabiera około 0,38 punktu kontrastu każdej parze przeniesionej z białego tła.
Trzy miejsca, w których liczba z macierzy jest nieprawdziwa
Macierz liczy kolory zadeklarowane. Przeglądarka rysuje kolory wynikowe, a te rozjeżdżają się z deklaracją w trzech powtarzalnych sytuacjach.
- Przezroczystość.
opacity: .7na tekście#767676leżącym na białym tle renderuje się jako#9f9f9f, czyli 2,64:1 — para, która w macierzy przechodziła z wynikiem 4,54:1, na ekranie jest poniżej progu. To samo dotyczyrgba()i warstw półprzezroczystych nad obrazem. Liczbę trzeba liczyć z koloru po zmieszaniu z tłem, nie z tokenu. - Gradient i obraz w tle. Gradient nie ma jednego koloru tła, ale ma stopy, i to one wyznaczają wynik. Biały tekst na gradiencie od
#4f46e5do#a5b4fcdaje 6,28:1 przy ciemnym stopie, 3,48:1 w połowie przejścia (#7a7df1) i 1,99:1 przy jasnym — pierwsza połowa napisu jest czytelna, druga nie. Dla obrazu w tle liczbę wyznacza najjaśniejszy fragment pod tekstem, a nie średnia kadru. - Druga paleta. Tryb ciemny to osobny zbiór par, nie przełącznik. Token
#767676, który na białym tle przechodził, na tle#121212daje 4,12:1 i odpada;#a8a8a8na tym samym tle daje 7,87:1. Plik par musi mieć sekcję dla każdego motywu, który strona faktycznie wystawia.
Obramowanie, fokus i stan: tu progiem jest 3:1, liczone z obu stron
Najczęściej pomijany wiersz macierzy nie dotyczy tekstu. Dokumentacja kryterium 1.4.11 Non-text Contrast wymaga 3:1 wobec kolorów sąsiadujących dla informacji wizualnej, która pozwala rozpoznać kontrolkę i jej stan — i również tutaj zastrzega, że 2,999:1 nie przechodzi.
Domyślne #d9d9d9 na obramowaniu pola formularza daje na białym tle 1,41:1, czyli mniej niż połowę wymaganej wartości. Najjaśniejszy szary, który przechodzi na białym, to #949494 z wynikiem 3,03:1; o jeden stopień jaśniejszy #959595 daje 2,99:1 i odpada, bo progu nie zaokrągla się w górę. Ten sam #949494 na karcie #f5f5f5 daje już 2,78:1. Obramowanie też jest parą, nie kolorem.
Praktyczne konsekwencje przy odbiorze komponentów z generatora:
- Pierścień fokusu mierzy się wobec obu kolorów, których dotyka — tła wewnątrz kontrolki i tła strony wokół niej. Ciemna obwódka na ciemnym przycisku spełnia próg wobec strony i nie spełnia wobec wnętrza.
- Stan zaznaczenia pokazywany samą zmianą koloru (checkbox, przełącznik, aktywna zakładka) wchodzi do macierzy jako osobna para, bo to po nim rozpoznaje się stan.
- Tekst zastępczy w polu (
placeholder) jest tekstem, nie dekoracją — obowiązuje go 4,5:1, a nie 3:1. Jasny szary placeholder to osobne, bardzo częste odrzucenie. - Wyłączenie dla kontrolek nieaktywnych obejmuje element, którego naprawdę nie da się użyć. Przycisk „Dalej” wyszarzony do momentu wypełnienia pola, ale wciąż klikalny, progu nie traci.
- Dokumentacja 1.4.11 zdejmuje wymóg z wyglądu, którego autor nie zmieniał — domyślna obwódka fokusu przeglądarki jest wyłączona z progu, dopóki arkusz jej nie nadpisze. Reset
outline: nonekończy to wyłączenie.
Których wierszy macierzy nie rozstrzygnie automat
Reguła color-contrast w axe-core porównuje kolor tekstu z jednym wyliczonym kolorem tła. Kiedy tła nie da się zredukować do jednej wartości — gradient, obraz, nakładka — reguła nie zwraca ani przejścia, ani błędu, lecz wynik niekompletny do przeglądu ręcznego; w zgłoszeniu dequelabs/axe-core#708 opisano to jako świadome zaniechanie, bo jednego koloru tła tam nie ma.
Konsekwencja jest prosta: zielony raport nie znaczy, że macierz jest policzona. Znaczy, że automat policzył wiersze, które dał się policzyć, a te z gradientem, obrazem i stanami pojawiającymi się po interakcji zostawił. Te wiersze liczy skrypt z pliku par — z kolorów po zmieszaniu, ze stopów gradientu i z osobnych pozycji dla hover, fokusu i zaznaczenia.
Lista kontrolna przed oddaniem
Kolejność poniżej zakłada, że projekt zmienia właściciela: po oddaniu nikt nie dopyta, co było zmierzone, a co przyjęte na oko. To ta sama logika, co w liście kontrolnej przed przekazaniem zadania — weryfikowalny stan zamiast zapewnienia.
- Wypisz powierzchnie, które strona faktycznie renderuje: tło strony, karty, stopkę, plakietki, modale, wiersze w stanie hover, oraz te same pozycje dla trybu ciemnego.
- Dla każdego komponentu dopisz do pliku par rolę, token tekstu, token tła i próg wynikający z rozmiaru czcionki w tym komponencie — 4,5:1 poniżej 24 px, 3:1 od 24 px.
- Dopisz pary spoza tekstu: obramowania pól, pierścień fokusu (dwa wiersze, po jednym na każdy sąsiadujący kolor), wskaźniki stanu, ikony bez etykiety tekstowej.
- Dla elementów z
opacityirgba()wstaw do pliku kolor po zmieszaniu z tłem, nie wartość tokenu. - Dla gradientów wstaw po jednym wierszu na stop, dla obrazów w tle — kolor najjaśniejszego fragmentu pod tekstem.
- Uruchom skrypt w pipeline tak, żeby kod wyjścia 1 zatrzymywał build; samo wypisanie ostrzeżeń na konsolę nic nie blokuje.
- Zapisz wyłączenia jawnie: logotyp, grafika dekoracyjna, kontrolka faktycznie nieaktywna. Lista wyłączeń to część dokumentacji odbioru, a nie wiedza w głowie wykonawcy.
Zmiana jednego tokenu zamiast trzydziestu klas
Dwa z trzech odrzuceń z przykładowego wyniku zamyka jedna edycja. Token tekstu przesunięty z #767676 na #707070 daje 4,54:1 na karcie #f5f5f5 i 4,95:1 na białym tle, więc przechodzi na obu powierzchniach naraz — bez dopisywania nadpisań w komponencie karty. Token linku przesunięty z #2f6feb na #1d4ed8 daje 6,14:1 na karcie i 6,70:1 na białym. Trzecie odrzucenie, obramowanie, wymaga decyzji projektowej: albo ciemniejszy szary liczony wobec najjaśniejszej powierzchni, na której pole występuje, albo rezygnacja z samego obramowania jako nośnika informacji o kontrolce.
Kolejność ma znaczenie przy wycenie. Poprawka w tokenie jest jedną zmianą w pliku zmiennych i jednym przebiegiem skryptu. Ta sama poprawka wprowadzana po akceptacji makiet, klasa po klasie w komponentach, które model już wygenerował, jest pracą na kilka godzin i wraca przy każdym nowym widoku. Jeśli klient świadczy jedną z usług objętych ustawą z 26 kwietnia 2024 r. o zapewnianiu spełniania wymagań dostępności niektórych produktów i usług, która obowiązuje od 28 czerwca 2025 r., stawką jest nie tylko czytelność — zakres podmiotowy sprawdza się jednak w przepisach ustawy, nie w zestawieniu na blogu, bo nie obejmuje ona każdej strony.
Decyzja do podjęcia przed następnym oddaniem jest jedna: czy plik par jedzie do repozytorium razem z paletą, czy kontrast pozostaje oceną wzrokową na spotkaniu odbiorczym. W pierwszym wariancie regresja kosztuje jeden czerwony build. W drugim wychodzi u klienta, po stronie, której nikt już nie pamięta.