Satu Stop Warna, Dua Nilai Kontras
Kontrak warna mengunci dua nilai dalam satu hue: teks aksen di stop 800, fill CTA tetap 600. Ambang kontras WCAG yang menentukan, bukan selera.
Ringkasan
Ganti accent text ke text-emerald-800 cuma lewat satu perintah sed, ternyata kena 409 kemunculan di 115 file. Satu hue butuh dua stop beda: teks wajib lolos 4,5 banding 1, fill button cukup 3 banding 1, jadi nggak bisa disatukan. Verifikasinya dua lapis, skrip kontras plus census CSS bundle, hasil akhirnya nilai hex lama nol.
Pagi itu perintahnya satu baris. Dua class accent text lama diganti lewat satu perintah sed dengan text-emerald-800 di seluruh frontend KotaPortal. Penghitung berhenti di 409 kemunculan tersebar di 115 file component dan page. Accent text kini render di #0369a1, stop 800.
Halaman beranda saya buka, button CTA utama saya klik. Di inspector, fill button terbaca #0080c9, stop 600. Hue-nya sama dengan accent text. Stop-nya beda.
Saya kira palette-nya yang keliru.
Satu warna, satu stop: asumsi yang salah
Waktu palette pertama disusun, aturan mentalnya: satu warna brand, satu stop Tailwind, dipakai di semua tempat. Accent text di text-emerald-600, fill button, stroke chart, semuanya stop 600. Dari kacamata itu, sed pagi tadi tampak seperti duplikasi yang tidak perlu.
Keliru di situ. Design contract yang sudah disahkan justru mengunci dua nilai di dalam satu hue, dan masing-masing punya peran kontras sendiri.
Dua anchor, dua ambang
Teks putih di atas fill #0080c9 terukur 4,26 banding 1. Ambang untuk komponen UI dan objek grafis adalah 3 banding 1 [2], jadi fill itu lolos sebagai button fill. Angka yang sama gagal sebagai teks, yang menuntut 4,5 banding 1 [1][4]. Accent text #0369a1 di atas putih terukur 5,93 banding 1, lolos ambang teks.
Satu stop tidak bisa melayani kedua peran itu. Accent text di stop 600 gagal ambang teks; fill di stop 800 justru melewati nilai design yang sudah diratifikasi. Jadi setiap peran kontras punya anchor stop-nya sendiri. Stop sisanya dideklarasikan sebagai turunan; family cool-gray diberi label turunan langsung di config.
Bentuk kontrak seperti ini wajar karena WCAG menetapkan rasio minimum berbeda per peran: teks isi 4,5 banding 1, teks besar 3 banding 1, komponen UI dan objek grafis 3 banding 1 terhadap warna di sekitarnya [1][2]. Palette default Tailwind menyediakan 11 langkah per warna, dari 50 paling terang sampai 950 paling gelap, dan palette yang sama dipakai oleh semua color utility: text, background, border, ring [3]. Nilai hex di artikel ini bukan isi palette default, melainkan nilai yang ditulis di config sebagai anchor. Palette yang sama melayani semua utility, pemetaan peran ke stop adalah bentuk kontrak yang paling langsung: setiap peran di-anchor di stop yang memenuhi ambangnya sendiri.
Dua lapis verifikasi
Lapis pertama adalah skrip kontras kecil yang menghitung rasio relative luminance WCAG untuk setiap pasangan yang disyaratkan. Hasilnya: accent text 5,93 banding 1, fill CTA 4,26 banding 1, hover CTA #006ba6 5,75 banding 1, accent dark-mode #38bdf8 7,22 banding 1, dan ink text 16,88 banding 1. Skrip ini dijalankan ulang setiap kali palette berubah.
const lum = (hex) => {
const c = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
.map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
};
const ratio = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
console.log(ratio("#0369a1", "#ffffff").toFixed(2)); // 5.93Verifikasi kedua lebih keras. Sebuah census terhadap CSS bundle hasil compile mencari triplet rgb dari nilai hex lama. Class di source bisa berbohong, karena class mati dan override di layer bisa menyimpan nilai lama. Bundle hasil compile tidak bisa.
grep -oE 'rgb\(37, 99, 235\)|rgb\(59, 130, 246\)' dist/assets/*.css | wc -l # expected: 0Census menemukan nol kemunculan nilai hex lama di source maupun bundle. Test suite lulus 602 dari 602.
Sebelum perintah itu dijalankan, ada satu audit wajib. Periksa pattern yang sama untuk varian dark:: bentuk bare, hover, group-hover, sampai ukuran besar. Di codebase ini hasilnya nol, diverifikasi sebelum penggantian. Di codebase yang punya varian dark:, penggantian buta akan merusak styling dark-mode dalam satu perintah.
Sisa yang dicatat, bukan disembunyikan
Dua pasangan kontras rendah tetap ada, keduanya pengecualian yang disahkan: meta text 2,85 banding 1 dan body di atas band background 4,28 banding 1. Keduanya tercatat di contract beserta batasan penggunaannya.
Garis finish sesungguhnya satu baris hasil census: nilai hex lama nol. Satu trade-off dibiarkan terbuka. Dua seri chart kini berbagi satu family hue, sehingga pembedaan antara keduanya menurun. Warnanya tidak diubah sebelum pemilik produk memutuskan. Statusnya tercatat pending, apa adanya.
Sumber
- W3C WAI, Understanding WCAG 2.2 SC 1.4.3 Contrast (Minimum)
- W3C WAI, Understanding WCAG 2.2 SC 1.4.11 Non-text Contrast
- Tailwind CSS Docs, Colors
- MDN, Color contrast