Przejdź do treści
Dostępność WCAG

#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.

Redakcjadeveloper front-end11 minkod + tabela
767676liczba z tezy tekstu

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: .7 na tekście #767676 leżą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 dotyczy rgba() 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 #4f46e5 do #a5b4fc daje 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 #121212 daje 4,12:1 i odpada; #a8a8a8 na 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: none koń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 opacity i rgba() 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.