| 1 | import { readFileSync } from "node:fs"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | |
| 4 | const CSS = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); |
| 5 | |
| 6 | function selectorBlock(selector: string): string { |
| 7 | const match = CSS.match(new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "s")); |
| 8 | if (!match) throw new Error(`Missing CSS selector: ${selector}`); |
| 9 | return match[1]; |
| 10 | } |
| 11 | |
| 12 | function customProperty(block: string, name: string): string { |
| 13 | const match = block.match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`, "i")); |
| 14 | if (!match) throw new Error(`Missing custom property: --${name}`); |
| 15 | return match[1]; |
| 16 | } |
| 17 | |
| 18 | function relativeLuminance(hex: string): number { |
| 19 | const channels = hex |
| 20 | .slice(1) |
| 21 | .match(/.{2}/g)! |
| 22 | .map((value) => Number.parseInt(value, 16) / 255) |
| 23 | .map((value) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)); |
| 24 | |
| 25 | return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; |
| 26 | } |
| 27 | |
| 28 | function contrastRatio(foreground: string, background: string): number { |
| 29 | const lighter = Math.max(relativeLuminance(foreground), relativeLuminance(background)); |
| 30 | const darker = Math.min(relativeLuminance(foreground), relativeLuminance(background)); |
| 31 | return (lighter + 0.05) / (darker + 0.05); |
| 32 | } |
| 33 | |
| 34 | describe("docs dark-theme contrast contract", () => { |
| 35 | it("keeps current and hover sidebar text at WCAG AA contrast", () => { |
| 36 | const darkThemes = [ |
| 37 | selectorBlock('html:not([data-theme="light"]) .docs-theme'), |
| 38 | selectorBlock('[data-theme="dark"] .docs-theme'), |
| 39 | ]; |
| 40 | |
| 41 | for (const dark of darkThemes) { |
| 42 | const accent = customProperty(dark, "docs-accent"); |
| 43 | const background = customProperty(dark, "paper"); |
| 44 | expect(contrastRatio(accent, background)).toBeGreaterThanOrEqual(4.5); |
| 45 | } |
| 46 | expect(CSS).toMatch(/\.docs-sidebar-link:hover,\s*\.docs-sidebar-link-current\s*{[^}]*color:\s*var\(--docs-accent\)/s); |
| 47 | }); |
| 48 | |
| 49 | it("keeps secondary button text at WCAG AA contrast", () => { |
| 50 | const darkThemes = [ |
| 51 | selectorBlock('html:not([data-theme="light"]) .docs-theme'), |
| 52 | selectorBlock('[data-theme="dark"] .docs-theme'), |
| 53 | ]; |
| 54 | |
| 55 | for (const dark of darkThemes) { |
| 56 | const text = customProperty(dark, "docs-button-text"); |
| 57 | const background = customProperty(dark, "docs-button-bg"); |
| 58 | expect(contrastRatio(text, background)).toBeGreaterThanOrEqual(4.5); |
| 59 | } |
| 60 | expect(selectorBlock(".docs-theme .portal-button-secondary")).toContain( |
| 61 | "color: var(--docs-button-text)", |
| 62 | ); |
| 63 | }); |
| 64 | }); |
| 65 |