diff --git a/components/WcagComplianceBadge.tsx b/components/WcagComplianceBadge.tsx new file mode 100644 index 0000000..00e6420 --- /dev/null +++ b/components/WcagComplianceBadge.tsx @@ -0,0 +1,26 @@ +import { CheckCircle2, XCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface ComplianceBadgeProps { + passed: boolean; + level: string; +} + +export function ComplianceBadge({ passed, level }: ComplianceBadgeProps) { + const Icon = passed ? CheckCircle2 : XCircle; + const statusClasses = passed + ? "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300" + : "bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300"; + + return ( +
+ + {level} +
+ ); +} diff --git a/components/ds/ColorPickerComponent.tsx b/components/ds/ColorPickerComponent.tsx new file mode 100644 index 0000000..e5e60eb --- /dev/null +++ b/components/ds/ColorPickerComponent.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ds/PopoverComponent"; +import { SketchPicker } from "react-color"; +import { cn } from "@/lib/utils"; +import { + normalizeHexForDisplay, + isValidHex, +} from "@/components/utils/wcag-color-contrast.utils"; +import { X } from "lucide-react"; + +const DEFAULT_COLOR = "#000000" as const; + +export interface ColorPickerProps { + value: string; + onChange: (color: string) => void; + className?: string; + disabled?: boolean; +} + +export const ColorPicker = React.forwardRef( + ({ value, onChange, className, disabled = false }, ref) => { + const [open, setOpen] = useState(false); + + const isValid = value ? isValidHex(value) : false; + const displayValue = normalizeHexForDisplay(value) ?? DEFAULT_COLOR; + + return ( + + + + + + onChange(color.hex.toUpperCase())} + disableAlpha + presetColors={[]} + /> + + + ); + } +); + +ColorPicker.displayName = "ColorPicker"; diff --git a/components/seo/WcagColorContrastCheckerSEO.tsx b/components/seo/WcagColorContrastCheckerSEO.tsx new file mode 100644 index 0000000..9bb25e7 --- /dev/null +++ b/components/seo/WcagColorContrastCheckerSEO.tsx @@ -0,0 +1,96 @@ +export default function WcagColorContrastCheckerSEO() { + return ( +
+
+

Check Color Contrast for WCAG Compliance

+

+ Ensure your designs meet accessibility standards with our free WCAG + color contrast checker. This tool helps you verify that your color + combinations meet WCAG 2.1 AA and AAA compliance requirements for both + normal and large text. +

+
+ +
+

How to Use Jam's WCAG Color Contrast Checker

+

+ Quickly verify color contrast ratios and ensure your designs meet + accessibility standards. Perfect for designers, developers, and anyone + working on accessible web content. +

+
    +
  • + Enter your foreground color: Input your text color in hex + format (e.g., #000000) +
  • +
  • + Enter your background color: Input your background color in + hex format (e.g., #FFFFFF) +
  • +
  • + Review the results: Instantly see the contrast ratio and WCAG + compliance status for both AA and AAA standards +
  • +
+
+ +
+

Understanding WCAG Color Contrast Requirements

+

+ The Web Content Accessibility Guidelines (WCAG) 2.1 define minimum + contrast ratios to ensure text is readable for people with visual + impairments. The contrast ratio measures the difference in luminance + between text and background colors. +

+
    +
  • + WCAG AA Normal text: Requires 4.5:1 contrast ratio (text + smaller than 18pt or 14pt bold) +
  • +
  • + WCAG AA Large text: Requires 3:1 contrast ratio (text 18pt+ + or 14pt+ bold) +
  • +
  • + WCAG AA Graphical objects and UI components: Requires 3:1 + contrast ratio for essential visual information like icons, buttons, + form controls, and other interactive elements +
  • +
  • + WCAG AAA Normal text: Requires 7:1 contrast ratio (text + smaller than 18pt or 14pt bold) +
  • +
  • + WCAG AAA Large text: Requires 4.5:1 contrast ratio (text + 18pt+ or 14pt+ bold) +
  • +
+
+ +
+

Graphical Objects and User Interface Components

+

+ WCAG 2.1 also requires a minimum 3:1 contrast ratio for graphical + objects and user interface components. This includes icons, buttons, + form controls, charts, graphs, and other essential visual elements + that convey information or require user interaction. Our tool checks + compliance for these elements, helping you ensure that all parts of + your interface are accessible to users with visual impairments. +

+
+ +
+

Why Color Contrast Matters for Accessibility

+

+ Proper color contrast is essential for accessibility. It ensures that + people with visual impairments, color blindness, or those viewing + content in bright sunlight can read your text and interact with your + interface. Meeting WCAG standards not only improves accessibility but + also helps you comply with legal requirements in many jurisdictions. + Our tool calculates the contrast ratio using the WCAG 2.1 formula, + which considers the relative luminance of colors. +

+
+
+ ); +} diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index e4c3ada..a5ac9ba 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -161,4 +161,10 @@ export const tools = [ "Generate cryptographically secure random strings with configurable character sets. Perfect for API keys, tokens, passwords, and secure identifiers.", link: "/utilities/random-string-generator", }, + { + title: "WCAG Color Contrast Checker", + description: + "Check color contrast ratios for WCAG AA and AAA compliance. Ensure your designs meet accessibility standards with our free color contrast checker tool.", + link: "/utilities/wcag-color-contrast-checker", + }, ]; diff --git a/components/utils/wcag-color-contrast.utils.test.ts b/components/utils/wcag-color-contrast.utils.test.ts new file mode 100644 index 0000000..a2a0c98 --- /dev/null +++ b/components/utils/wcag-color-contrast.utils.test.ts @@ -0,0 +1,484 @@ +import { + hexToRgb, + isValidHex, + rgbToHex, + normalizeHexForDisplay, + normalizeHexInput, + getRelativeLuminance, + getContrastRatio, + checkWCAGCompliance, + calculateContrast, + getContrastDescription, + RGB, +} from "./wcag-color-contrast.utils"; + +describe("wcag-color-contrast.utils", () => { + describe("hexToRgb", () => { + test("should convert valid hex color to RGB", () => { + expect(hexToRgb("#000000")).toEqual({ r: 0, g: 0, b: 0 }); + expect(hexToRgb("#FFFFFF")).toEqual({ r: 255, g: 255, b: 255 }); + expect(hexToRgb("#FF0000")).toEqual({ r: 255, g: 0, b: 0 }); + expect(hexToRgb("#00FF00")).toEqual({ r: 0, g: 255, b: 0 }); + expect(hexToRgb("#0000FF")).toEqual({ r: 0, g: 0, b: 255 }); + expect(hexToRgb("#767676")).toEqual({ r: 118, g: 118, b: 118 }); + }); + + test("should convert 3-digit hex color to RGB (expanded)", () => { + expect(hexToRgb("#000")).toEqual({ r: 0, g: 0, b: 0 }); + expect(hexToRgb("#fff")).toEqual({ r: 255, g: 255, b: 255 }); + expect(hexToRgb("#FFF")).toEqual({ r: 255, g: 255, b: 255 }); + expect(hexToRgb("#f00")).toEqual({ r: 255, g: 0, b: 0 }); + expect(hexToRgb("#0f0")).toEqual({ r: 0, g: 255, b: 0 }); + expect(hexToRgb("#00f")).toEqual({ r: 0, g: 0, b: 255 }); + expect(hexToRgb("#abc")).toEqual({ r: 170, g: 187, b: 204 }); + }); + + test("should convert hex without # prefix", () => { + expect(hexToRgb("000000")).toEqual({ r: 0, g: 0, b: 0 }); + expect(hexToRgb("FFFFFF")).toEqual({ r: 255, g: 255, b: 255 }); + expect(hexToRgb("fff")).toEqual({ r: 255, g: 255, b: 255 }); + expect(hexToRgb("000")).toEqual({ r: 0, g: 0, b: 0 }); + }); + + test("should return null for invalid hex colors", () => { + expect(hexToRgb("#GGGGGG")).toBeNull(); + expect(hexToRgb("invalid")).toBeNull(); + expect(hexToRgb("#12")).toBeNull(); + expect(hexToRgb("#1234567")).toBeNull(); + expect(hexToRgb("#123456789")).toBeNull(); + }); + + test("should handle 4 and 5 digit hex colors (treated as 3 during typing)", () => { + const result4 = hexToRgb("#fff0"); + expect(result4).not.toBeNull(); + expect(result4).toEqual({ r: 255, g: 255, b: 255 }); + + const result5 = hexToRgb("#12345"); + expect(result5).not.toBeNull(); + }); + + test("should return null for invalid lengths", () => { + expect(hexToRgb("#12")).toBeNull(); + expect(hexToRgb("#1234567")).toBeNull(); + expect(hexToRgb("#12345678")).toBeNull(); + }); + }); + + describe("isValidHex", () => { + test("should validate correct hex colors", () => { + expect(isValidHex("#000000")).toBe(true); + expect(isValidHex("#FFFFFF")).toBe(true); + expect(isValidHex("#ffffff")).toBe(true); + expect(isValidHex("#FF5733")).toBe(true); + expect(isValidHex("000000")).toBe(true); + expect(isValidHex("FFFFFF")).toBe(true); + }); + + test("should validate 3-digit hex colors", () => { + expect(isValidHex("#000")).toBe(true); + expect(isValidHex("#fff")).toBe(true); + expect(isValidHex("#FFF")).toBe(true); + expect(isValidHex("#abc")).toBe(true); + expect(isValidHex("000")).toBe(true); + expect(isValidHex("fff")).toBe(true); + expect(isValidHex("ABC")).toBe(true); + }); + + test("should validate 4 and 5 digit hex colors (accepted during typing)", () => { + expect(isValidHex("#fff0")).toBe(true); + expect(isValidHex("#12345")).toBe(true); + }); + + test("should reject invalid hex colors", () => { + expect(isValidHex("#GGGGGG")).toBe(false); + expect(isValidHex("invalid")).toBe(false); + expect(isValidHex("#12")).toBe(false); + expect(isValidHex("#1234567")).toBe(false); + expect(isValidHex("#123456789")).toBe(false); + expect(isValidHex("")).toBe(false); + }); + }); + + describe("rgbToHex", () => { + test("should convert RGB to hex", () => { + expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe("#000000"); + expect(rgbToHex({ r: 255, g: 255, b: 255 })).toBe("#FFFFFF"); + expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe("#FF0000"); + expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe("#00FF00"); + expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe("#0000FF"); + expect(rgbToHex({ r: 118, g: 118, b: 118 })).toBe("#767676"); + }); + + test("should pad single digit values with zeros", () => { + expect(rgbToHex({ r: 10, g: 5, b: 1 })).toBe("#0A0501"); + expect(rgbToHex({ r: 0, g: 15, b: 255 })).toBe("#000FFF"); + }); + + test("should convert to uppercase", () => { + expect(rgbToHex({ r: 170, g: 187, b: 204 })).toBe("#AABBCC"); + }); + }); + + describe("normalizeHexForDisplay", () => { + test("should normalize 3-digit hex to 6-digit", () => { + expect(normalizeHexForDisplay("#000")).toBe("#000000"); + expect(normalizeHexForDisplay("#fff")).toBe("#FFFFFF"); + expect(normalizeHexForDisplay("#FFF")).toBe("#FFFFFF"); + expect(normalizeHexForDisplay("#abc")).toBe("#AABBCC"); + expect(normalizeHexForDisplay("#f00")).toBe("#FF0000"); + }); + + test("should normalize 4 and 5 digit hex to 6-digit (using first 3)", () => { + expect(normalizeHexForDisplay("#fff0")).toBe("#FFFFFF"); + expect(normalizeHexForDisplay("#12345")).toBe("#112233"); + }); + + test("should return 6-digit hex as is (uppercase)", () => { + expect(normalizeHexForDisplay("#000000")).toBe("#000000"); + expect(normalizeHexForDisplay("#ffffff")).toBe("#FFFFFF"); + expect(normalizeHexForDisplay("#FFFFFF")).toBe("#FFFFFF"); + expect(normalizeHexForDisplay("#FF5733")).toBe("#FF5733"); + }); + + test("should work without # prefix", () => { + expect(normalizeHexForDisplay("000")).toBe("#000000"); + expect(normalizeHexForDisplay("fff")).toBe("#FFFFFF"); + expect(normalizeHexForDisplay("FFFFFF")).toBe("#FFFFFF"); + }); + + test("should return null for invalid hex", () => { + expect(normalizeHexForDisplay("invalid")).toBeNull(); + expect(normalizeHexForDisplay("#GGGGGG")).toBeNull(); + expect(normalizeHexForDisplay("#12")).toBeNull(); + expect(normalizeHexForDisplay("#1234567")).toBeNull(); + expect(normalizeHexForDisplay("")).toBeNull(); + }); + }); + + describe("normalizeHexInput", () => { + test("should add # prefix if missing", () => { + expect(normalizeHexInput("000000")).toBe("#000000"); + expect(normalizeHexInput("fff")).toBe("#FFF"); + expect(normalizeHexInput("FFFFFF")).toBe("#FFFFFF"); + }); + + test("should keep # prefix if present", () => { + expect(normalizeHexInput("#000000")).toBe("#000000"); + expect(normalizeHexInput("#fff")).toBe("#FFF"); + expect(normalizeHexInput("#FFFFFF")).toBe("#FFFFFF"); + }); + + test("should convert to uppercase", () => { + expect(normalizeHexInput("#ffffff")).toBe("#FFFFFF"); + expect(normalizeHexInput("#abc")).toBe("#ABC"); + expect(normalizeHexInput("abc")).toBe("#ABC"); + }); + + test("should remove invalid characters", () => { + expect(normalizeHexInput("#FF-33")).toBe("#FF33"); + expect(normalizeHexInput("FF 33")).toBe("#FF33"); + expect(normalizeHexInput("#123!@#456")).toBe("#123#45"); + }); + + test("should trim whitespace", () => { + expect(normalizeHexInput(" #fff ")).toBe("#FFF"); + expect(normalizeHexInput(" fff ")).toBe("#FFF"); + }); + + test("should limit to 7 characters (# + 6 digits)", () => { + expect(normalizeHexInput("#123456789")).toBe("#123456"); + expect(normalizeHexInput("123456789")).toBe("#123456"); + }); + + test("should handle empty string", () => { + expect(normalizeHexInput("")).toBe(""); + expect(normalizeHexInput(" ")).toBe(""); + }); + }); + + describe("getRelativeLuminance", () => { + test("should calculate luminance for black", () => { + const black: RGB = { r: 0, g: 0, b: 0 }; + const luminance = getRelativeLuminance(black); + expect(luminance).toBeCloseTo(0, 5); + }); + + test("should calculate luminance for white", () => { + const white: RGB = { r: 255, g: 255, b: 255 }; + const luminance = getRelativeLuminance(white); + expect(luminance).toBeCloseTo(1, 5); + }); + + test("should calculate luminance for gray", () => { + const gray: RGB = { r: 128, g: 128, b: 128 }; + const luminance = getRelativeLuminance(gray); + expect(luminance).toBeGreaterThan(0); + expect(luminance).toBeLessThan(1); + }); + + test("should calculate luminance for specific colors", () => { + const red: RGB = { r: 255, g: 0, b: 0 }; + const redLuminance = getRelativeLuminance(red); + expect(redLuminance).toBeGreaterThan(0); + expect(redLuminance).toBeLessThan(1); + + const green: RGB = { r: 0, g: 255, b: 0 }; + const greenLuminance = getRelativeLuminance(green); + expect(greenLuminance).toBeGreaterThan(redLuminance); + }); + }); + + describe("getContrastRatio", () => { + test("should calculate maximum contrast (black on white)", () => { + const black: RGB = { r: 0, g: 0, b: 0 }; + const white: RGB = { r: 255, g: 255, b: 255 }; + const ratio = getContrastRatio({ color1: black, color2: white }); + expect(ratio).toBeCloseTo(21, 1); + }); + + test("should calculate minimum contrast (same colors)", () => { + const gray: RGB = { r: 128, g: 128, b: 128 }; + const ratio = getContrastRatio({ color1: gray, color2: gray }); + expect(ratio).toBeCloseTo(1, 2); + }); + + test("should calculate contrast for #767676 on #FFFFFF", () => { + const gray: RGB = { r: 118, g: 118, b: 118 }; + const white: RGB = { r: 255, g: 255, b: 255 }; + const ratio = getContrastRatio({ color1: gray, color2: white }); + expect(ratio).toBeCloseTo(4.54, 2); + }); + + test("should be symmetric (order doesn't matter)", () => { + const color1: RGB = { r: 100, g: 150, b: 200 }; + const color2: RGB = { r: 200, g: 100, b: 150 }; + const ratio1 = getContrastRatio({ color1, color2 }); + const ratio2 = getContrastRatio({ color1: color2, color2: color1 }); + expect(ratio1).toBe(ratio2); + }); + }); + + describe("checkWCAGCompliance", () => { + test("should pass all levels for high contrast (21:1)", () => { + const result = checkWCAGCompliance(21); + expect(result.ratio).toBe(21); + expect(result.aa.normal).toBe(true); + expect(result.aa.large).toBe(true); + expect(result.aa.graphicalObjects).toBe(true); + expect(result.aaa.normal).toBe(true); + expect(result.aaa.large).toBe(true); + }); + + test("should pass AA but not AAA normal for 4.5:1", () => { + const result = checkWCAGCompliance(4.5); + expect(result.ratio).toBe(4.5); + expect(result.aa.normal).toBe(true); + expect(result.aa.large).toBe(true); + expect(result.aa.graphicalObjects).toBe(true); + expect(result.aaa.normal).toBe(false); + expect(result.aaa.large).toBe(true); + }); + + test("should pass only AA large for 3:1", () => { + const result = checkWCAGCompliance(3); + expect(result.ratio).toBe(3); + expect(result.aa.normal).toBe(false); + expect(result.aa.large).toBe(true); + expect(result.aa.graphicalObjects).toBe(true); + expect(result.aaa.normal).toBe(false); + expect(result.aaa.large).toBe(false); + }); + + test("should fail all levels for low contrast (1.5:1)", () => { + const result = checkWCAGCompliance(1.5); + expect(result.ratio).toBe(1.5); + expect(result.aa.normal).toBe(false); + expect(result.aa.large).toBe(false); + expect(result.aa.graphicalObjects).toBe(false); + expect(result.aaa.normal).toBe(false); + expect(result.aaa.large).toBe(false); + }); + + test("should pass AAA normal for 7:1", () => { + const result = checkWCAGCompliance(7); + expect(result.ratio).toBe(7); + expect(result.aa.normal).toBe(true); + expect(result.aa.large).toBe(true); + expect(result.aa.graphicalObjects).toBe(true); + expect(result.aaa.normal).toBe(true); + expect(result.aaa.large).toBe(true); + }); + + test("should pass graphical objects for 3:1", () => { + const result = checkWCAGCompliance(3); + expect(result.ratio).toBe(3); + expect(result.aa.graphicalObjects).toBe(true); + expect(result.aa.normal).toBe(false); + }); + + test("should fail graphical objects for 2.9:1", () => { + const result = checkWCAGCompliance(2.9); + expect(result.ratio).toBe(2.9); + expect(result.aa.graphicalObjects).toBe(false); + }); + }); + + describe("calculateContrast", () => { + test("should calculate contrast for black on white", () => { + const result = calculateContrast({ + foregroundHex: "#000000", + backgroundHex: "#FFFFFF", + }); + expect(result).not.toBeNull(); + if (result) { + expect(result.ratio).toBeCloseTo(21, 1); + expect(result.aa.normal).toBe(true); + expect(result.aa.large).toBe(true); + expect(result.aa.graphicalObjects).toBe(true); + expect(result.aaa.normal).toBe(true); + expect(result.aaa.large).toBe(true); + } + }); + + test("should calculate contrast for #767676 on #FFFFFF", () => { + const result = calculateContrast({ + foregroundHex: "#767676", + backgroundHex: "#FFFFFF", + }); + expect(result).not.toBeNull(); + if (result) { + expect(result.ratio).toBeCloseTo(4.54, 2); + expect(result.aa.normal).toBe(true); + expect(result.aa.large).toBe(true); + expect(result.aa.graphicalObjects).toBe(true); + expect(result.aaa.normal).toBe(false); + expect(result.aaa.large).toBe(true); + } + }); + + test("should return null for invalid hex colors", () => { + expect( + calculateContrast({ + foregroundHex: "invalid", + backgroundHex: "#FFFFFF", + }) + ).toBeNull(); + expect( + calculateContrast({ + foregroundHex: "#000000", + backgroundHex: "invalid", + }) + ).toBeNull(); + expect( + calculateContrast({ + foregroundHex: "#12", + backgroundHex: "#FFFFFF", + }) + ).toBeNull(); + expect( + calculateContrast({ + foregroundHex: "#000000", + backgroundHex: "#123456789", + }) + ).toBeNull(); + }); + + test("should work with 4 and 5 digit hex colors (treated as 3)", () => { + const result = calculateContrast({ + foregroundHex: "#fff0", + backgroundHex: "#12345", + }); + expect(result).not.toBeNull(); + }); + + test("should return null for invalid lengths", () => { + expect( + calculateContrast({ + foregroundHex: "#1234567", + backgroundHex: "#000", + }) + ).toBeNull(); + expect( + calculateContrast({ + foregroundHex: "#ffffff00", + backgroundHex: "#000", + }) + ).toBeNull(); + }); + + test("should work with 3-digit hex colors", () => { + const result = calculateContrast({ + foregroundHex: "#000", + backgroundHex: "#fff", + }); + expect(result).not.toBeNull(); + if (result) { + expect(result.ratio).toBeCloseTo(21, 1); + } + + const result2 = calculateContrast({ + foregroundHex: "#f00", + backgroundHex: "#0f0", + }); + expect(result2).not.toBeNull(); + if (result2) { + expect(result2.ratio).toBeGreaterThan(1); + } + }); + + test("should work with hex colors without # prefix", () => { + const result = calculateContrast({ + foregroundHex: "000000", + backgroundHex: "FFFFFF", + }); + expect(result).not.toBeNull(); + if (result) { + expect(result.ratio).toBeCloseTo(21, 1); + } + }); + + test("should calculate contrast for low contrast colors", () => { + const result = calculateContrast({ + foregroundHex: "#CCCCCC", + backgroundHex: "#FFFFFF", + }); + expect(result).not.toBeNull(); + if (result) { + expect(result.ratio).toBeLessThan(2); + expect(result.aa.normal).toBe(false); + expect(result.aa.large).toBe(false); + } + }); + }); + + describe("getContrastDescription", () => { + test("should return 'Excellent contrast' for ratio >= 7", () => { + expect(getContrastDescription(7)).toBe("Excellent contrast"); + expect(getContrastDescription(10)).toBe("Excellent contrast"); + expect(getContrastDescription(21)).toBe("Excellent contrast"); + }); + + test("should return 'Good contrast' for ratio >= 4.5 and < 7", () => { + expect(getContrastDescription(4.5)).toBe("Good contrast"); + expect(getContrastDescription(5)).toBe("Good contrast"); + expect(getContrastDescription(6.9)).toBe("Good contrast"); + }); + + test("should return 'Minimum contrast for large text' for ratio >= 3 and < 4.5", () => { + expect(getContrastDescription(3)).toBe("Minimum contrast for large text"); + expect(getContrastDescription(3.5)).toBe( + "Minimum contrast for large text" + ); + expect(getContrastDescription(4.4)).toBe( + "Minimum contrast for large text" + ); + }); + + test("should return 'Insufficient contrast' for ratio < 3", () => { + expect(getContrastDescription(2.9)).toBe("Insufficient contrast"); + expect(getContrastDescription(2)).toBe("Insufficient contrast"); + expect(getContrastDescription(1.5)).toBe("Insufficient contrast"); + expect(getContrastDescription(1)).toBe("Insufficient contrast"); + }); + }); +}); diff --git a/components/utils/wcag-color-contrast.utils.ts b/components/utils/wcag-color-contrast.utils.ts new file mode 100644 index 0000000..984f2e8 --- /dev/null +++ b/components/utils/wcag-color-contrast.utils.ts @@ -0,0 +1,240 @@ +export interface RGB { + r: number; + g: number; + b: number; +} + +export interface ContrastResult { + ratio: number; + aa: { + normal: boolean; + large: boolean; + graphicalObjects: boolean; + }; + aaa: { + normal: boolean; + large: boolean; + }; +} + +interface ContrastDescriptionRule { + condition: boolean; + result: string; +} + +type HexLength = 3 | 4 | 5 | 6; + +export const WCAG = { + AA: { + NORMAL_THRESHOLD: 4.5, + LARGE_THRESHOLD: 3, + GRAPHICAL_OBJECTS_THRESHOLD: 3, + }, + AAA: { + NORMAL_THRESHOLD: 7, + LARGE_THRESHOLD: 4.5, + }, +} as const; + +// WCAG 2.1 constants - Reference: https://www.w3.org/WAI/GL/wiki/Relative_luminance +// Note: WCAG 2.x uses 0.03928, while the correct IEC standard uses 0.04045. +// For 8-bit color values, the difference is not significant. We use 0.03928 to match WCAG 2.x specification. +const SRGB_GAMMA_THRESHOLD = 0.03928 as const; +const SRGB_LOW_VALUE_DIVISOR = 12.92 as const; +const SRGB_GAMMA_OFFSET = 0.055 as const; +const SRGB_GAMMA_DIVISOR = 1.055 as const; +const SRGB_GAMMA_EXPONENT = 2.4 as const; + +const LUMINANCE_R_COEFFICIENT = 0.2126 as const; +const LUMINANCE_G_COEFFICIENT = 0.7152 as const; +const LUMINANCE_B_COEFFICIENT = 0.0722 as const; + +const CONTRAST_RATIO_CONSTANT = 0.05 as const; + +const HEX_COLOR_PATTERN: RegExp = /^[0-9A-Fa-f]{3,6}$/; +const INVALID_HEX_CHARS: RegExp = /[^#0-9A-F]/gi; + +const parseHexToRgb = (normalizedHex: string, length: HexLength): RGB => { + const shouldExpand = [3, 4, 5].includes(length); + const expandedLength = shouldExpand ? 3 : length; + + let hexToParse = normalizedHex; + + if (shouldExpand) { + hexToParse = hexToParse + .substring(0, expandedLength) + .split("") + .map((char) => char + char) + .join(""); + } + + const r = parseInt(hexToParse.substring(0, 2), 16); + const g = parseInt(hexToParse.substring(2, 4), 16); + const b = parseInt(hexToParse.substring(4, 6), 16); + + return { r, g, b }; +}; + +const hexToRgbMapper: Record RGB> = { + 3: (normalizedHex) => parseHexToRgb(normalizedHex, 3), + 4: (normalizedHex) => parseHexToRgb(normalizedHex, 4), + 5: (normalizedHex) => parseHexToRgb(normalizedHex, 5), + 6: (normalizedHex) => parseHexToRgb(normalizedHex, 6), +}; + +export const hexToRgb = (hex: string): RGB | null => { + const normalizedHex = hex.startsWith("#") ? hex.slice(1) : hex; + + if (!HEX_COLOR_PATTERN.test(normalizedHex)) return null; + + const length = normalizedHex.length; + + if (length < 3 || length > 6) return null; + + const lengthKey = length as HexLength; + const parser = hexToRgbMapper[lengthKey]; + + if (!parser) return null; + + return parser(normalizedHex); +}; + +export const isValidHex = (hex: string): boolean => { + const normalizedHex = hex.startsWith("#") ? hex.slice(1) : hex; + return ( + HEX_COLOR_PATTERN.test(normalizedHex) && + normalizedHex.length >= 3 && + normalizedHex.length <= 6 + ); +}; + +const rgbComponentToHex = (component: number): string => { + return component.toString(16).padStart(2, "0").toUpperCase(); +}; + +export const rgbToHex = (rgb: RGB): string => { + return `#${rgbComponentToHex(rgb.r)}${rgbComponentToHex(rgb.g)}${rgbComponentToHex(rgb.b)}`; +}; + +export const normalizeHexForDisplay = (hex: string): string | null => { + if (!isValidHex(hex)) return null; + + const rgb = hexToRgb(hex); + if (!rgb) return null; + + return rgbToHex(rgb); +}; + +/** + * Calculates the relative luminance of a color according to WCAG 2.1 + * Reference: https://www.w3.org/WAI/GL/wiki/Relative_luminance + */ +export const getRelativeLuminance = (rgb: RGB): number => { + const normalize = (value: number): number => { + const val = value / 255; + return val <= SRGB_GAMMA_THRESHOLD + ? val / SRGB_LOW_VALUE_DIVISOR + : Math.pow( + (val + SRGB_GAMMA_OFFSET) / SRGB_GAMMA_DIVISOR, + SRGB_GAMMA_EXPONENT + ); + }; + + const r = normalize(rgb.r); + const g = normalize(rgb.g); + const b = normalize(rgb.b); + + return ( + LUMINANCE_R_COEFFICIENT * r + + LUMINANCE_G_COEFFICIENT * g + + LUMINANCE_B_COEFFICIENT * b + ); +}; + +/** + * Calculates the contrast ratio between two colors according to WCAG 2.1 + * Reference: https://www.w3.org/WAI/GL/wiki/Contrast_ratio + */ +export const getContrastRatio = ({ + color1, + color2, +}: { + color1: RGB; + color2: RGB; +}): number => { + const lum1 = getRelativeLuminance(color1); + const lum2 = getRelativeLuminance(color2); + + const lighter = Math.max(lum1, lum2); + const darker = Math.min(lum1, lum2); + + return ( + (lighter + CONTRAST_RATIO_CONSTANT) / (darker + CONTRAST_RATIO_CONSTANT) + ); +}; + +export const checkWCAGCompliance = (ratio: number): ContrastResult => { + return { + ratio, + aa: { + normal: ratio >= WCAG.AA.NORMAL_THRESHOLD, + large: ratio >= WCAG.AA.LARGE_THRESHOLD, + graphicalObjects: ratio >= WCAG.AA.GRAPHICAL_OBJECTS_THRESHOLD, + }, + aaa: { + normal: ratio >= WCAG.AAA.NORMAL_THRESHOLD, + large: ratio >= WCAG.AAA.LARGE_THRESHOLD, + }, + }; +}; + +export const calculateContrast = ({ + foregroundHex, + backgroundHex, +}: { + foregroundHex: string; + backgroundHex: string; +}): ContrastResult | null => { + const fgRgb = hexToRgb(foregroundHex); + const bgRgb = hexToRgb(backgroundHex); + + if (!fgRgb || !bgRgb) { + return null; + } + + const ratio = getContrastRatio({ color1: fgRgb, color2: bgRgb }); + return checkWCAGCompliance(ratio); +}; + +export const normalizeHexInput = (value: string): string => { + let normalized = value.trim().toUpperCase(); + + if (normalized && !normalized.startsWith("#")) { + normalized = "#" + normalized; + } + + normalized = normalized.replace(INVALID_HEX_CHARS, ""); + + if (normalized.length > 7) { + normalized = normalized.slice(0, 7); + } + + return normalized; +}; + +export const getContrastDescription = (ratio: number): string => { + const rules: ContrastDescriptionRule[] = [ + { + condition: ratio >= WCAG.AAA.NORMAL_THRESHOLD, + result: "Excellent contrast", + }, + { condition: ratio >= WCAG.AA.NORMAL_THRESHOLD, result: "Good contrast" }, + { + condition: ratio >= WCAG.AA.LARGE_THRESHOLD, + result: "Minimum contrast for large text", + }, + ]; + + const matchedRule = rules.find((rule) => rule.condition); + return matchedRule?.result ?? "Insufficient contrast"; +}; diff --git a/package-lock.json b/package-lock.json index 92491f2..2fc3360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "next-themes": "^0.3.0", "papaparse": "^5.4.1", "react": "^18", + "react-color": "^2.19.3", "react-dom": "^18", "react-syntax-highlighter": "^15.5.0", "tailwind-merge": "^2.4.0", @@ -44,6 +45,7 @@ "@types/node": "^20", "@types/papaparse": "^5.3.14", "@types/react": "^18", + "@types/react-color": "^3.0.13", "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^7.2.0", @@ -824,6 +826,15 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4328,6 +4339,19 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-color": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz", + "integrity": "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/reactcss": "*" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", @@ -4346,6 +4370,16 @@ "@types/react": "*" } }, + "node_modules/@types/reactcss": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz", + "integrity": "sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -10010,8 +10044,13 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -10109,6 +10148,12 @@ "tmpl": "1.0.5" } }, + "node_modules/material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==", + "license": "ISC" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11183,7 +11228,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -11278,6 +11322,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-color": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", + "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", + "license": "MIT", + "dependencies": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.15", + "lodash-es": "^4.17.15", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -11293,8 +11355,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-remove-scroll": { "version": "2.5.5", @@ -11378,6 +11439,15 @@ "react": ">= 0.14.0" } }, + "node_modules/reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.0.1" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -12610,6 +12680,12 @@ "node": ">=0.8" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index e97b11e..7c29faa 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "next-themes": "^0.3.0", "papaparse": "^5.4.1", "react": "^18", + "react-color": "^2.19.3", "react-dom": "^18", "react-syntax-highlighter": "^15.5.0", "tailwind-merge": "^2.4.0", @@ -48,6 +49,7 @@ "@types/node": "^20", "@types/papaparse": "^5.3.14", "@types/react": "^18", + "@types/react-color": "^3.0.13", "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^7.2.0", diff --git a/pages/utilities/wcag-color-contrast-checker.tsx b/pages/utilities/wcag-color-contrast-checker.tsx new file mode 100644 index 0000000..0a3f0c3 --- /dev/null +++ b/pages/utilities/wcag-color-contrast-checker.tsx @@ -0,0 +1,250 @@ +import { useState, useMemo, useCallback } from "react"; +import PageHeader from "@/components/PageHeader"; +import { Card } from "@/components/ds/CardComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import { Input } from "@/components/ds/InputComponent"; +import { ColorPicker } from "@/components/ds/ColorPickerComponent"; +import WcagColorContrastCheckerSEO from "@/components/seo/WcagColorContrastCheckerSEO"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import GitHubContribution from "@/components/GitHubContribution"; +import Meta from "@/components/Meta"; +import { + calculateContrast, + isValidHex, + normalizeHexInput, + normalizeHexForDisplay, + getContrastDescription, + WCAG, +} from "@/components/utils/wcag-color-contrast.utils"; +import { AlertCircle, Star } from "lucide-react"; +import { ComplianceBadge } from "@/components/WcagComplianceBadge"; + +const TEXT_CONTENT = + "Jam makes developers' lives easier with powerful debugging tools." as const; + +const DEFAULT_FOREGROUND_COLOR = "#000000"; +const DEFAULT_BACKGROUND_COLOR = "#FFFFFF"; + +export default function WcagColorContrastChecker() { + const [foreground, setForeground] = useState(DEFAULT_FOREGROUND_COLOR); + const [background, setBackground] = useState(DEFAULT_BACKGROUND_COLOR); + + const handleColorChange = useCallback( + (setter: (value: string) => void) => + (event: React.ChangeEvent) => { + const normalized = normalizeHexInput(event.target.value); + setter(normalized); + }, + [] + ); + + const fgValid = isValidHex(foreground); + const bgValid = isValidHex(background); + + const normalizedForeground = + normalizeHexForDisplay(foreground) ?? DEFAULT_FOREGROUND_COLOR; + const normalizedBackground = + normalizeHexForDisplay(background) ?? DEFAULT_BACKGROUND_COLOR; + + const contrastResult = useMemo( + () => + calculateContrast({ + foregroundHex: foreground, + backgroundHex: background, + }), + [foreground, background] + ); + + return ( +
+ +
+ + +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + {fgValid && bgValid && contrastResult && ( + <> + +
+ +
+ {contrastResult.ratio.toFixed(2)}:1 +
+

+ {getContrastDescription(contrastResult.ratio)} +

+
+ + )} + + {fgValid && bgValid && contrastResult && ( + <> + +
+ +
+
+

+ Normal Text +

+
+ + +
+
+

+ {TEXT_CONTENT} +

+
+
+ +
+

Large Text

+
+ + +
+
+

+ {TEXT_CONTENT} +

+
+
+ +
+

+ Graphical Objects and User Interface Components +

+
+ +
+
+ + +
+
+
+
+ + )} + + {(!fgValid || !bgValid) && ( + <> + +
+ + + Please enter valid hex color codes (e.g., #000000 or #FFF) + +
+ + )} +
+
+
+ + + + +
+ +
+
+ ); +} + +const Divider = () => { + return
; +}; diff --git a/styles/globals.css b/styles/globals.css index ca358e9..6e0397c 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -63,40 +63,52 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } + .content-wrapper section { @apply mb-9; } + .content-wrapper h1 { @apply text-4xl font-bold leading-tight mb-4; } + .content-wrapper h2 { @apply text-lg font-semibold leading-snug mb-1; } + .content-wrapper p { @apply text-base text-muted-foreground mb-2 font-light; } + .content-wrapper ul, .content-wrapper ol { @apply ml-3 mb-2 list-disc; } + .content-wrapper ul { @apply list-disc pl-2 text-muted-foreground; } + .content-wrapper li { @apply mb-1 font-light; } + .content-wrapper a { @apply text-primary underline; } + .content-wrapper b { @apply font-semibold text-card-foreground; } + .content-wrapper pre { @apply rounded-xl text-sm font-mono p-4 !important; } + .content-wrapper kbd { @apply text-sm bg-muted px-1 py-1 rounded-lg; }