วัด contrast จากพิกเซลจริง: แคปสองรอบ แล้วเทียบเฉพาะจุดที่มีตัวอักษร
ตัวเลข contrast ที่คำนวณจากสองสีใน CSS บอกได้เฉพาะเมื่อพื้นหลังเป็นสีเรียบสีเดียว พอพื้นหลังเป็นกระจกฝ้าเหนือรูปถ่าย ตัวเลขนั้นก็เป็นแค่ความเชื่อ ทางที่ตรงที่สุดคือวัดจากพิกเซลที่เบราว์เซอร์วาดออกมาจริง
สรุปใน 1 นาที
- อาการ/ปัญหา
- ตัวตรวจ contrast ที่อ่านสีจาก CSS ใช้ไม่ได้บนกระจกฝ้า gradient และรูปถ่าย
- ใครควรอ่าน
- คนทำเว็บที่ต้องวัด contrast บนพื้นหลังที่ไม่ใช่สีเรียบ
- ทางแก้แบบสั้น
- แคปหน้าสองรอบ รอบสองให้ตัวอักษรโปร่งใส หาพิกเซลของ glyph จากส่วนต่าง วัด contrast จากพิกเซลจริง แล้วแก้ด้วย token สีที่พลิกตาม dark mode
พอดีผมทำ mockup เว็บแบบ glass คือกรอบกระจกฝ้าวางบนพื้นรูปถ่ายกับ gradient มีทั้งธีมสว่างและธีมมืด เครื่องตรวจ contrast ที่อ่านค่า color กับ background-color จาก CSS รายงานว่าผ่านเกือบหมด แต่พอผมเปิด dark mode ดูด้วยตา เจอตัวอักษรสีน้ำเงินแบรนด์วางบนพื้นกรมท่าที่แทบมองไม่เห็น กับการ์ดสีขาวที่ตัวอักษรข้างในจางจนต้องเพ่ง เครื่องตรวจบอกว่าผ่าน ตาผมบอกว่าอ่านไม่ออก ผมเลยเชื่อตา แล้วเขียนเครื่องมือใหม่ที่วัดจากพิกเซลที่ render จริงแทน
ชื่อโปรเจกต์และไฟล์จริงผมไม่เอามาเขียน สีแบรนด์ในตัวอย่างเป็นค่าสมมติ #1d6fd8 ส่วนโค้ดผมเขียนย่อใหม่ให้เห็นแก่นของวิธี ไม่ได้คัดทั้งไฟล์มาวาง
ทำไมการอ่านค่า CSS ถึงใช้ไม่ได้กับกระจกและรูปถ่าย
เครื่องตรวจแบบ CSS-only ทำสองอย่างคือ อ่านสีตัวอักษร แล้วไต่ขึ้นไปหา element บรรพบุรุษตัวแรกที่มี background-color มาเป็นพื้นหลัง จากนั้นคำนวณอัตราส่วนความสว่างตามสูตร WCAG วิธีนี้ถูกต้องเมื่อพื้นหลังเป็นสีทึบ แต่มันพังเมื่อเจอสามสถานการณ์
- กระจกฝ้า: พื้นหลังของการ์ดเป็น
rgba(255,255,255,.3)ที่ซ้อนเหนือรูป สีที่ตาเห็นคือผลผสมของการ์ดกับสิ่งที่อยู่ข้างหลัง ซึ่ง CSS ไม่รู้ - gradient: ตัวอักษรบรรทัดเดียวกันอาจเริ่มบนส่วนอ่อนและจบบนส่วนเข้ม ค่าเฉลี่ยค่าเดียวใช้ไม่ได้
- รูปถ่าย: ความสว่างหลังตัวอักษรเปลี่ยนไปทุกพิกเซล CSS ไม่มีข้อมูลนี้เลย

ข้อสุดท้ายคือสิ่งที่ผมต้องการ ถ้าผมได้ภาพของ "พื้นหลังล้วน ๆ" มาไว้ข้างภาพปกติ ผมก็รู้ได้ว่าหลังตัวอักษรแต่ละตัวมีสีอะไร
ไอเดีย: แคปสองรอบ ให้ตัวอักษรโปร่งใสในรอบที่สอง
หลักการมีแค่นี้ ถ่ายหน้าเว็บสองครั้งโดยไม่ขยับอะไรเลย รอบ A ถ่ายหน้าปกติ รอบ B ฉีด CSS ให้ตัวอักษรทุกตัวโปร่งใส เลย layout ไม่เปลี่ยน เพราะ color: transparent ไม่กระทบขนาดกล่อง ภาพ B เลยเป็นพื้นหลังจริงทั้งหมด จากนั้นเอาสองภาพมาลบกัน จุดที่ต่างกันคือพิกเซลของ glyph ส่วนสีของพิกเซลเดียวกันในภาพ B คือพื้นหลังจริงที่อยู่หลัง glyph นั้น
ขั้นตอนของสคริปต์ทีละข้อ
ผมใช้ Playwright กับ Chromium ที่ติดตั้งไว้ รันบน Node เจ็ดขั้นตามลำดับนี้ และแต่ละขั้นมีเหตุผลที่ผมเจอมาเอง ไม่ได้ใส่ไว้เฉย ๆ
- โหลดหน้า แล้วเลื่อนไปทั้งหน้าให้รูปแบบ lazy โหลดครบ ไม่งั้นพื้นหลังที่แคปได้เป็นกล่องว่างแทนรูป
- ตั้ง viewport ให้สูงเท่าหน้า (จำกัดสูงสุด 16000 px) เพราะการแคปแบบ fullPage จะ relayout ทำให้กล่องที่วัดพิกัดไว้ไม่ตรงกับพิกเซลที่ถ่าย
- ซ่อน element ที่เป็น
position: fixedปุ่มแชตหรือปุ่มแก้ไขที่ลอยอยู่พอ viewport สูงเท่าหน้าจะไปทับท้ายหน้า - เก็บรายการ element ที่มี text node ของตัวเอง ข้ามตัวที่ซ่อน, opacity ใกล้ศูนย์, หรือเล็กกว่า 2 px พร้อมสีตัวอักษร ขนาด น้ำหนัก และกล่อง
- แคปภาพ A
- ฉีด CSS ให้ตัวอักษรโปร่งใส แล้วแคปภาพ B
- คำนวณต่อ element แล้วพิมพ์
[OK]หรือ[FAIL] nพร้อมแถวที่แย่สุด
โค้ดฉบับย่อที่ผมลองรันกับหน้าทดสอบเล็ก ๆ แล้ว ติดตั้งแค่ npm i playwright pngjs
// audit.cjs - วัด contrast จากพิกเซลที่ render จริง (ฉบับย่อ)
const { chromium } = require('playwright');
const { PNG } = require('pngjs');
const lin = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
const pct = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))];
async function audit(url, scheme, width) {
const b = await chromium.launch();
const page = await b.newPage({ viewport: { width, height: 900 }, colorScheme: scheme });
await page.goto(url, { waitUntil: 'networkidle' });
// 1) เลื่อนให้รูป lazy โหลด แล้วขยาย viewport เท่าความสูงหน้า (กัน fullPage relayout)
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 600) {
scrollTo(0, y); await new Promise(r => setTimeout(r, 80));
}
scrollTo(0, 0);
});
const h = Math.min(16000, await page.evaluate(() => document.documentElement.scrollHeight));
await page.setViewportSize({ width, height: h });
// 2) ซ่อน fixed แล้วเก็บ element ที่มี text node ของตัวเอง
const items = await page.evaluate(() => {
for (const el of document.querySelectorAll('body *'))
if (getComputedStyle(el).position === 'fixed') el.style.visibility = 'hidden';
const out = [];
for (const el of document.querySelectorAll('body *')) {
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none' || +cs.opacity < 0.05) continue;
if (![...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) continue;
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) continue;
out.push({ tag: el.tagName.toLowerCase(), text: el.textContent.trim().slice(0, 30),
color: cs.color, size: parseFloat(cs.fontSize), weight: +cs.fontWeight,
x: Math.round(r.left), y: Math.round(r.top + scrollY),
w: Math.round(r.width), h: Math.round(r.height) });
}
return out;
});
// 3) รอบ A = ปกติ, รอบ B = ตัวอักษรโปร่งใส เหลือแต่พื้นหลังจริง
const A = PNG.sync.read(await page.screenshot());
await page.addStyleTag({ content: '*{color:transparent!important;-webkit-text-stroke-color:transparent!important;text-shadow:none!important;caret-color:transparent!important}' });
const B = PNG.sync.read(await page.screenshot());
await b.close();
return { items, A, B };
}
ครึ่งหลังคือส่วนคำนวณ ซึ่งเป็นหัวใจของเรื่องนี้ ผมแยกเป็นฟังก์ชันของมันเอง
// ต่อจาก audit.cjs - คำนวณต่อ element
function score({ items, A, B }) {
const fails = [];
for (const it of items) {
const m = it.color.match(/[\d.]+/g).map(Number);
const fg = m.slice(0, 3), alpha = m.length > 3 ? m[3] : 1;
// พิกเซลของ glyph = จุดที่ A ต่างจาก B (ผลรวมต่าง RGB >= 60)
// สีพื้นหลัง = ค่าของ B ที่จุดเหล่านั้น (ขอบมนและ padding เลยไม่ถูกนับเป็นพื้นหลัง)
const bg = [];
for (let y = Math.max(0, it.y); y < Math.min(A.height, it.y + it.h); y++)
for (let x = Math.max(0, it.x); x < Math.min(A.width, it.x + it.w); x++) {
const i = (y * A.width + x) * 4;
const d = Math.abs(A.data[i] - B.data[i]) + Math.abs(A.data[i + 1] - B.data[i + 1])
+ Math.abs(A.data[i + 2] - B.data[i + 2]);
if (d >= 60) bg.push([B.data[i], B.data[i + 1], B.data[i + 2]]);
}
if (bg.length < 12) continue; // glyph น้อยไป (ฉบับเต็มถอยไปใช้ทั้งกล่อง)
// สีตัวอักษรที่มี alpha ถูกผสมกับค่าเฉลี่ยของพื้นหลัง
const avg = [0, 1, 2].map(k => bg.reduce((s, p) => s + p[k], 0) / bg.length);
const mixed = fg.map((c, k) => c * alpha + avg[k] * (1 - alpha));
const L = lum(mixed);
// เทียบกับ percentile ที่ 5 และ 95 ของความสว่างพื้นหลัง เอาตัวที่แย่กว่า
const ls = bg.map(lum).sort((a, b) => a - b);
const worst = Math.min(ratio(L, pct(ls, 0.05)), ratio(L, pct(ls, 0.95)));
const large = it.size >= 24 || (it.size >= 18.66 && it.weight >= 700);
const need = large ? 3 : 4.5;
if (worst < need) fails.push({ ...it, worst: +worst.toFixed(2), need });
}
return fails;
}
ที่ใช้ percentile 5 กับ 95 แทนค่าเฉลี่ยก็เพราะพื้นหลังที่เป็นรูปถ่ายมีทั้งส่วนสว่างและมืดในกล่องเดียว ถ้าใช้ค่าเฉลี่ยตัวอักษรที่ครึ่งหนึ่งอยู่บนส่วนที่กลืนกับพื้นจะรอดไปได้ การเทียบกับปลายทั้งสองข้างแล้วเอาตัวที่แย่กว่า เท่ากับถามว่า "จุดที่แย่ที่สุดที่ตัวอักษรไปวางอยู่ อ่านออกไหม"
ข้อความทั่วไปต้องได้ 4.5:1 ตัวใหญ่ (ตั้งแต่ 24 px หรือตั้งแต่ 18.66 px ถ้าเป็นตัวหนา) ใช้ 3:1 และ icon font ใช้ 3:1 ผมรันทั้งความกว้าง 1440 และ 390 กับทั้งโหมดสว่างและมืดผ่าน colorScheme ของ Playwright เพราะปัญหาจริงของผมโผล่เฉพาะ dark mode
ลองกับหน้าทดสอบเล็ก ๆ ก่อนเชื่อ
ก่อนเอาไปใช้กับงานจริง ผมสร้างหน้าทดสอบเล็ก ๆ ที่พื้นหลังเป็น gradient จากสีน้ำเงินไปขาว มีการ์ดโปร่งแสงวางทับ ข้างในเป็นตัวอักษรสีขาวหนึ่งบรรทัดกับสีดำหนึ่งบรรทัด แล้วรันสคริปต์ตามด้านบน ตัวสีดำผ่านทุกโหมด ตัวสีขาวบนการ์ดโปร่งแสงล้มในทุกรอบ
$ node audit.cjs file:///path/to/test.html
[FAIL] light 1440px - 1 element
2.49:1 (ต้อง 4.5) <p> "white on glass"
[FAIL] light 390px - 1 element
1.7:1 (ต้อง 4.5) <p> "white on glass"
[FAIL] dark 1440px - 1 element
2.49:1 (ต้อง 4.5) <p> "white on glass"
[FAIL] dark 390px - 1 element
1.7:1 (ต้อง 4.5) <p> "white on glass"
ตัวเลขชุดนี้มาจากหน้าทดสอบที่ผมสร้างเองเท่านั้น ไม่ใช่ผลตรวจของโปรเจกต์ไหน แต่มันแสดงสิ่งที่ผมต้องการเห็น คือสีขาวบนน้ำเงินแบรนด์ตามสูตรควรผ่าน (ประมาณ 4.9:1) แต่พอวางบนการ์ดโปร่งแสงเหนือ gradient ที่ปลายขาว ตัวอักษรเลยอยู่บนพื้นที่อ่อนกว่ามาก และสองความกว้างหน้าจอให้ตัวเลขต่างกัน เพราะ gradient ยืดไม่เท่ากัน เครื่องตรวจแบบอ่านค่า CSS ไม่มีทางเห็นความต่างนี้
สคริปต์ไม่วัดข้อความที่มาจาก ::before หรือ ::after (เช่นเลขขั้นตอนที่ใส่ผ่าน content) พวกนั้นต้องดูด้วยตา และข้อความที่อยู่บนวิดีโอหรือ canvas ที่เคลื่อนไหวได้ ผลที่ได้คือภาพ ณ จังหวะที่แคปเท่านั้น ผมไม่ได้วัดว่าสคริปต์ใช้เวลานานแค่ไหนกับหน้ายาว ๆ เลยไม่ขอบอกตัวเลข
อ่านรายการ FAIL ให้เป็น
เวลารายการ [FAIL] ยาว ถ้าไล่แก้ตามลำดับที่พิมพ์ออกมาจะเหนื่อยเปล่า ผมเลยเรียงจากอัตราส่วนแย่สุดก่อน แล้วดูว่าแถวที่แย่เหล่านั้นมีรูปแบบร่วมกันไหม เช่นเป็น class เดียวกัน อยู่ใน section เดียวกัน หรือใช้สีแบรนด์ตัวเดียวกันเป็นตัวอักษร ถ้ามีรูปแบบร่วม แปลว่าต้นเหตุอยู่ที่ token หรือ component ตัวเดียว แก้ที่นั่นทีเดียวแถวที่เหลือก็หายตามกัน สิ่งที่ผมไม่ทำคือเปิด DevTools แล้วเปลี่ยนสีทีละ element เพราะแก้ได้แต่ไม่ได้เรียนรู้อะไร ตัวสคริปต์เองก็พิมพ์ tag กับข้อความสั้น ๆ ของ element ออกมา เพื่อให้เห็นรูปแบบนั้นได้เร็ว
อย่าแก้ทีละจุด ใช้ token ที่พลิกตามโหมด
พอได้รายการ [FAIL] มาแล้ว สิ่งที่ล่อใจคือไล่แก้สีทีละ element ซึ่งผมลองแล้วไม่ได้ผล แก้จุดหนึ่งผ่านในโหมดสว่าง ไปพังในโหมดมืด แล้วพรุ่งนี้มีการ์ดใบใหม่ก็พังซ้ำ ทางที่ได้ผลคือเปลี่ยนวิธีคิดเรื่องสี ให้ใช้ semantic token ที่รู้ตัวเองว่าอยู่โหมดไหน

:root {
--brand: #1d6fd8; /* สีแบรนด์ ใช้กับพื้น/เส้น/ปุ่มทึบเท่านั้น */
--brand-text: #1d6fd8; /* สีแบรนด์เมื่อใช้เป็นตัวอักษรหรือไอคอน */
--surface-2: rgba(255, 255, 255, .72); /* การ์ดย่อยบนกระจก */
--tint: rgba(29, 111, 216, .10); /* พื้นของชิป/ปุ่ม ghost */
--tint-border: rgba(29, 111, 216, .35);
--ok: #17703f; /* ไอคอนสถานะสำเร็จ เข้มขึ้นในโหมดสว่าง */
}
@media (prefers-color-scheme: dark) {
:root {
--brand-text: #a9cbf6; /* tint อ่อน แทนสีแบรนด์ตรง ๆ */
--surface-2: rgba(255, 255, 255, .07);
--tint: rgba(169, 203, 246, .12);
--tint-border: rgba(169, 203, 246, .35);
--ok: #86d9ad; /* อ่อนลงในโหมดมืด */
}
}
.link, .icon-accent { color: var(--brand-text); } /* ไม่ใช้ var(--brand) เป็นตัวอักษร */
.card-inner { background: var(--surface-2); } /* ไม่ใช้ #fff ตายตัว */
.chip, .btn-ghost { background: var(--tint); border: 1px solid var(--tint-border); color: var(--brand-text); }
สองแนวทางเทียบกันได้ตามนี้
เปลี่ยนสีที่ element ที่ล้ม
ผ่านโหมดเดียว พังอีกโหมด และการ์ดใหม่ที่เพิ่มทีหลังพังซ้ำ ต้องไล่ตรวจเองทุกครั้ง ตัวเลือกสีกระจายอยู่ทั่วไฟล์
ห้ามใช้สีแบรนด์ตรง ๆ เป็นตัวอักษร
ตัวอักษรและไอคอนใช้ --brand-text การ์ดย่อยใช้ --surface-2 ชิปใช้ --tint ทุกตัวพลิกตามโหมด แก้ที่เดียวทั้งเว็บเปลี่ยนตาม
นอกจากชุด token ข้างบน ยังมีเรื่องเฉพาะจุดที่เครื่องวัดจับได้และผมแก้ด้วยวิธีเฉพาะ
- ข้อความบนรูป: เพิ่มความทึบของ overlay หรือ gradient ที่ครอบจนผ่าน แถบ CTA ของผมไปลงตัวที่ประมาณ .9
- ตัวเลขแบบ outline ที่ใช้
-webkit-text-strokeล้มบนพื้นมืด ผมใส่สีเติมด้วย--brand-textให้มีเนื้อสีจริง - dropdown ในแถบเมนูแบบเบลอ: ตัวอักษรของ dropdown ทะลุเห็นพื้นหลัง วิธีที่ผมแก้คือย้ายการเบลอของ nav ไปอยู่ที่
::beforeของ nav แทน
/* ก่อน: เบลอที่ nav โดยตรง dropdown ที่ซ้อนอยู่ข้างในเบลอพื้นหลังไม่ได้ ตัวอักษรทะลุ */
/* .nav { backdrop-filter: blur(14px); } */
/* หลัง: ให้ ::before เป็นตัวเบลอ nav เองไม่มี backdrop-filter */
.nav { position: sticky; top: 0; }
.nav::before {
content: ""; position: absolute; inset: 0; z-index: -1;
backdrop-filter: blur(14px);
}
เรื่อง dropdown นี้ผมรู้แค่ว่าย้ายแล้วอาการหาย ส่วนกลไกในเบราว์เซอร์ที่ทำให้ backdrop-filter ซ้อนกันไม่เห็นพื้นหลังเดิม ผมไม่ได้พิสูจน์ในบทความนี้
วงจรที่ผมใช้ทุกครั้ง
ลำดับที่ผมทำจริงคือรันสคริปต์กับสี่ชุด (สว่างกับมืด คูณ 1440 กับ 390) ดูรายการ [FAIL] เรียงจากแย่สุด แก้ที่ token ไม่ใช่ที่ element รันซ้ำจนได้ [OK] ทั้งสี่ชุด แล้วสุดท้ายเปิดภาพ crop ของโหมดมืดตรงส่วนที่ผมเคยชี้ด้วยตาว่าอ่านไม่ออก ดูให้เห็นเองอีกรอบ เพราะเครื่องมือวัดบอกได้ว่ามีเสียงดังจากตรงไหน แต่ตัดสินแทนตาไม่ได้ทั้งหมด โดยเฉพาะส่วนที่เป็น ::before ที่สคริปต์มองไม่เห็น
ส่วนตัวผมถ้าหน้าเว็บมีกระจกฝ้าหรือรูปเป็นพื้นหลัง ผมเลิกเชื่อเครื่องตรวจแบบอ่านค่า CSS อย่างเดียวแล้วครับ ใครจะเอาสคริปต์ย่อด้านบนไปปรับเกณฑ์หรือ percentile ให้เหมาะกับงานตัวเองก็ตามสะดวก เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย