From 1be841440fdda06fa3de1e3e0e887b48216229a9 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sun, 10 Aug 2025 23:34:24 -0500 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=92=A5=20First=20draft=20of=20v1.0?= =?UTF-8?q?=20luxon-style=20code,=20expanded=20to=20support=20a=20lot=20mo?= =?UTF-8?q?re=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Contrastrast.ts | 201 +++++++++++++++++++++++++++++++++++++++++ constants.ts | 67 +++++++++++++- types/ContrastTypes.ts | 26 ++++++ utils/contrastRatio.ts | 25 +++++ utils/textContrast.ts | 42 +++++++++ 6 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 Contrastrast.ts create mode 100644 types/ContrastTypes.ts create mode 100644 utils/contrastRatio.ts create mode 100644 utils/textContrast.ts diff --git a/.gitignore b/.gitignore index b327b04..5286104 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ coverage # Build artifacts npm/ + +# Development and planning +__SPECS__/ diff --git a/Contrastrast.ts b/Contrastrast.ts new file mode 100644 index 0000000..0446ec6 --- /dev/null +++ b/Contrastrast.ts @@ -0,0 +1,201 @@ +import type { RGBValues } from "./types/RGB.types.ts"; +import type { + ContrastOptions, + ContrastResult, + HSLValues, + TextSize, + WCAGLevel, +} from "./types/ContrastTypes.ts"; +import { getRGBFromColorString } from "./helpers/colorStringParsers.ts"; +import { + BRIGHTNESS_COEFFICIENTS, + CONTRAST_THRESHOLD, + GAMMA_CORRECTION, + HSL_CONVERSION, + LUMINANCE_COEFFICIENTS, + RGB_BOUNDS, + WCAG_LEVELS, +} from "./constants.ts"; +import { contrastRatio } from "./utils/contrastRatio.ts"; +import { textContrast } from "./utils/textContrast.ts"; + +export class Contrastrast { + private readonly rgb: RGBValues; + + constructor(colorString: string) { + this.rgb = getRGBFromColorString(colorString); + } + + // Factory Methods + static fromHex = (hex: string): Contrastrast => { + const normalizedHex = hex.startsWith("#") ? hex : `#${hex}`; + return new Contrastrast(normalizedHex); + }; + + static fromRgb(r: number, g: number, b: number): Contrastrast; + static fromRgb(rgb: RGBValues): Contrastrast; + static fromRgb( + rOrRgb: number | RGBValues, + g?: number, + b?: number, + ): Contrastrast { + if (typeof rOrRgb === "object") { + return new Contrastrast(`rgb(${rOrRgb.r}, ${rOrRgb.g}, ${rOrRgb.b})`); + } + return new Contrastrast(`rgb(${rOrRgb}, ${g}, ${b})`); + } + + static fromHsl(h: number, s: number, l: number): Contrastrast; + static fromHsl(hsl: HSLValues): Contrastrast; + static fromHsl( + hOrHsl: number | HSLValues, + s?: number, + l?: number, + ): Contrastrast { + if (typeof hOrHsl === "object") { + return new Contrastrast(`hsl(${hOrHsl.h}, ${hOrHsl.s}%, ${hOrHsl.l}%)`); + } + return new Contrastrast(`hsl(${hOrHsl}, ${s}%, ${l}%)`); + } + + static parse = (colorString: string): Contrastrast => + new Contrastrast(colorString); + + // Conversion Methods + toHex = (includeHash: boolean = true): string => { + const toHex = (n: number) => { + const hex = Math.round(n).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + + const hexValue = toHex(this.rgb.r) + toHex(this.rgb.g) + toHex(this.rgb.b); + return includeHash ? `#${hexValue}` : hexValue; + }; + + toRgb = (): RGBValues => ({ ...this.rgb }); + + toRgbString = (): string => + `rgb(${this.rgb.r}, ${this.rgb.g}, ${this.rgb.b})`; + + toHsl = (): HSLValues => { + const r = this.rgb.r / RGB_BOUNDS.MAX; + const g = this.rgb.g / RGB_BOUNDS.MAX; + const b = this.rgb.b / RGB_BOUNDS.MAX; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s; + const l = (max + min) / 2; + + if (max === min) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > HSL_CONVERSION.LIGHTNESS_THRESHOLD + ? d / (2 - max - min) + : d / (max + min); + + switch (max) { + case r: + h = (g - b) / d + (g < b ? HSL_CONVERSION.HUE_SECTORS : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + default: + h = 0; + } + h /= HSL_CONVERSION.HUE_SECTORS; + } + + return { + h: Math.round(h * HSL_CONVERSION.FULL_CIRCLE_DEGREES), + s: Math.round(s * HSL_CONVERSION.PERCENTAGE_MULTIPLIER), + l: Math.round(l * HSL_CONVERSION.PERCENTAGE_MULTIPLIER), + }; + }; + + toHslString = (): string => { + const hsl = this.toHsl(); + return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`; + }; + + // WCAG 2.1 luminance calculation + luminance = (): number => { + const gammaCorrect = (colorValue: number): number => { + const c = colorValue / RGB_BOUNDS.MAX; + return c <= GAMMA_CORRECTION.THRESHOLD + ? c / GAMMA_CORRECTION.LINEAR_DIVISOR + : Math.pow( + (c + GAMMA_CORRECTION.GAMMA_OFFSET) / + GAMMA_CORRECTION.GAMMA_DIVISOR, + GAMMA_CORRECTION.GAMMA_EXPONENT, + ); + }; + + const rLinear = gammaCorrect(this.rgb.r); + const gLinear = gammaCorrect(this.rgb.g); + const bLinear = gammaCorrect(this.rgb.b); + + return ( + LUMINANCE_COEFFICIENTS.RED * rLinear + + LUMINANCE_COEFFICIENTS.GREEN * gLinear + + LUMINANCE_COEFFICIENTS.BLUE * bLinear + ); + }; + + brightness = (): number => + (this.rgb.r * BRIGHTNESS_COEFFICIENTS.RED + + this.rgb.g * BRIGHTNESS_COEFFICIENTS.GREEN + + this.rgb.b * BRIGHTNESS_COEFFICIENTS.BLUE) / + BRIGHTNESS_COEFFICIENTS.DIVISOR; + + /* Utility Methods */ + isLight = (): boolean => this.brightness() > CONTRAST_THRESHOLD; + + isDark = (): boolean => !this.isLight(); + + contrastRatio = (color: Contrastrast | string): number => + contrastRatio(this, color); + + textContrast = ( + otherColor: Contrastrast | string, + role: "foreground" | "background" = "background", + options: ContrastOptions = {}, + ): number | ContrastResult => { + if (role === "background") { + // Current color is background, otherColor is foreground + return textContrast(otherColor, this, options); + } else { + // Current color is foreground, otherColor is background + return textContrast(this, otherColor, options); + } + }; + + // WCAG Compliance Helper + meetsWCAG = ( + otherColor: Contrastrast | string, + role: "foreground" | "background", + level: WCAGLevel, + textSize: TextSize = "normal", + ): boolean => { + const ratio = this.textContrast(otherColor, role); + const required = WCAG_LEVELS[level][textSize]; + return (typeof ratio === "number" ? ratio : ratio.ratio) >= required; + }; + + // Utility Methods + equals = (color: Contrastrast | string): boolean => { + const other = color instanceof Contrastrast + ? color + : new Contrastrast(color); + return ( + this.rgb.r === other.rgb.r && + this.rgb.g === other.rgb.g && + this.rgb.b === other.rgb.b + ); + }; +} diff --git a/constants.ts b/constants.ts index b4e1caa..c5f33f5 100644 --- a/constants.ts +++ b/constants.ts @@ -1,7 +1,72 @@ -import { ContrastrastOptions } from "./types/contrastrastOptionts.types.ts"; +import type { ContrastrastOptions } from "./types/contrastrastOptionts.types.ts"; +import type { TextSize, WCAGLevel } from "./types/ContrastTypes.ts"; +// WC3 AERT brightness contrast threshold +// Source: https://www.w3.org/TR/AERT/#color-contrast export const CONTRAST_THRESHOLD = 124; +// WC3 AERT brightness calculation coefficients -- more efficient for quick calculations +// Source: https://www.w3.org/TR/AERT/#color-contrast +export const BRIGHTNESS_COEFFICIENTS = { + RED: 299, + GREEN: 587, + BLUE: 114, + DIVISOR: 1000, +} as const; + +// WCAG 2.1 relative luminance calculation coefficients +// Source: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +export const LUMINANCE_COEFFICIENTS = { + RED: 0.2126, + GREEN: 0.7152, + BLUE: 0.0722, +} as const; + +// Gamma correction constants for sRGB color space +// Source: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +export const GAMMA_CORRECTION = { + THRESHOLD: 0.04045, + LINEAR_DIVISOR: 12.92, + GAMMA_OFFSET: 0.055, + GAMMA_DIVISOR: 1.055, + GAMMA_EXPONENT: 2.4, +} as const; + +// WCAG 2.1 contrast ratio calculation constants +// Source: https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio +export const CONTRAST_RATIO = { + LUMINANCE_OFFSET: 0.05, +} as const; + +// WCAG 2.1 minimum contrast thresholds +// Source: https://www.w3.org/TR/WCAG21/#contrast-minimum +// Source: https://www.w3.org/TR/WCAG21/#contrast-enhanced +export const WCAG_LEVELS: Record> = { + AA: { + normal: 4.5, + large: 3.0, + }, + AAA: { + normal: 7.0, + large: 4.5, + }, +} as const; + +// RGB color space bounds +export const RGB_BOUNDS = { + MIN: 0, + MAX: 255, +} as const; + +// HSL conversion constants +// Source: https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB +export const HSL_CONVERSION = { + LIGHTNESS_THRESHOLD: 0.5, + HUE_SECTORS: 6, + FULL_CIRCLE_DEGREES: 360, + PERCENTAGE_MULTIPLIER: 100, +} as const; + export const DEFAULT_CONTRASTRAST_OPTIONS: ContrastrastOptions = { fallbackOption: "dark", throwErrorOnUnhandled: false, diff --git a/types/ContrastTypes.ts b/types/ContrastTypes.ts new file mode 100644 index 0000000..de846d7 --- /dev/null +++ b/types/ContrastTypes.ts @@ -0,0 +1,26 @@ +export type WCAGLevel = "AA" | "AAA"; + +export type TextSize = "normal" | "large"; + +export type ContrastResult = { + ratio: number; + passes: boolean; + details: { + required: number; + actual: number; + level: WCAGLevel; + textSize: TextSize; + }; +}; + +export type ContrastOptions = { + level?: WCAGLevel; + textSize?: TextSize; + returnDetails?: boolean; +}; + +export type HSLValues = { + h: number; + s: number; + l: number; +}; diff --git a/utils/contrastRatio.ts b/utils/contrastRatio.ts new file mode 100644 index 0000000..5400d1c --- /dev/null +++ b/utils/contrastRatio.ts @@ -0,0 +1,25 @@ +import { Contrastrast } from "../Contrastrast.ts"; +import { CONTRAST_RATIO } from "../constants.ts"; + +/** + * Calculate the WCAG 2.1 contrast ratio between two colors + * @param color1 First color (Contrastrast instance or color string) + * @param color2 Second color (Contrastrast instance or color string) + * @returns Contrast ratio (1:1 to 21:1) + */ +export const contrastRatio = ( + color1: Contrastrast | string, + color2: Contrastrast | string, +): number => { + const c1 = color1 instanceof Contrastrast ? color1 : new Contrastrast(color1); + const c2 = color2 instanceof Contrastrast ? color2 : new Contrastrast(color2); + + const l1 = c1.luminance(); + const l2 = c2.luminance(); + + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + + return (lighter + CONTRAST_RATIO.LUMINANCE_OFFSET) / + (darker + CONTRAST_RATIO.LUMINANCE_OFFSET); +}; diff --git a/utils/textContrast.ts b/utils/textContrast.ts new file mode 100644 index 0000000..a74f54b --- /dev/null +++ b/utils/textContrast.ts @@ -0,0 +1,42 @@ +import type { Contrastrast } from "../Contrastrast.ts"; +import type { + ContrastOptions, + ContrastResult, +} from "../types/ContrastTypes.ts"; +import { WCAG_LEVELS } from "../constants.ts"; +import { contrastRatio } from "./contrastRatio.ts"; + +/** + * Analyze text contrast between foreground and background colors + * @param foreground Foreground color (text color) + * @param background Background color + * @param options Configuration options for WCAG compliance checking + * @returns Contrast ratio number or detailed ContrastResult object + */ +export const textContrast = ( + foreground: Contrastrast | string, + background: Contrastrast | string, + options: ContrastOptions = {}, +): number | ContrastResult => { + const { level = "AA", textSize = "normal", returnDetails = false } = options; + + const ratio = contrastRatio(foreground, background); + + if (!returnDetails) { + return ratio; + } + + const required = WCAG_LEVELS[level][textSize]; + const passes = ratio >= required; + + return { + ratio, + passes, + details: { + required, + actual: ratio, + level, + textSize, + }, + }; +}; From 9f664fa7bf31cf1a7467e11329a9ac37b7a2f537 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sun, 10 Aug 2025 23:52:10 -0500 Subject: [PATCH 02/22] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20linter,=20run=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/pre-commit | 2 +- .lintstagedrc | 2 +- deno.lock | 173 ++----------------------- helpers/colorStringParsers.ts | 2 +- helpers/rgbConverters.test.ts | 2 +- helpers/rgbConverters.ts | 2 +- modules/textContrastForBGColor.test.ts | 4 +- modules/textContrastForBGColor.ts | 2 +- 8 files changed, 19 insertions(+), 170 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index cfc53f9..b723c3f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -deno run lint-staged +deno task lint-staged diff --git a/.lintstagedrc b/.lintstagedrc index 319777a..2c4c7b7 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,5 +1,5 @@ { "*.{js,ts,cjs}": ["deno lint --fix", "deno fmt"], - "*.{json,md}": "deno fmt", + "*.{json,md}": "deno fmt" } diff --git a/deno.lock b/deno.lock index b665a3f..af50f1c 100644 --- a/deno.lock +++ b/deno.lock @@ -1,167 +1,11 @@ { - "version": "4", + "version": "5", "specifiers": { - "jsr:@david/code-block-writer@^13.0.2": "13.0.3", - "jsr:@deno/cache-dir@~0.10.3": "0.10.3", - "jsr:@deno/dnt@~0.41.3": "0.41.3", - "jsr:@std/assert@0.223": "0.223.0", - "jsr:@std/assert@0.226": "0.226.0", - "jsr:@std/assert@^1.0.8": "1.0.8", - "jsr:@std/async@^1.0.8": "1.0.8", - "jsr:@std/bytes@0.223": "0.223.0", - "jsr:@std/data-structures@^1.0.4": "1.0.4", - "jsr:@std/expect@*": "1.0.8", - "jsr:@std/expect@^1.0.8": "1.0.8", - "jsr:@std/fmt@0.223": "0.223.0", - "jsr:@std/fmt@1": "1.0.3", - "jsr:@std/fs@0.223": "0.223.0", - "jsr:@std/fs@1": "1.0.5", - "jsr:@std/fs@^1.0.5": "1.0.5", - "jsr:@std/fs@~0.229.3": "0.229.3", - "jsr:@std/internal@^1.0.5": "1.0.5", - "jsr:@std/io@0.223": "0.223.0", - "jsr:@std/path@0.223": "0.223.0", - "jsr:@std/path@1": "1.0.8", - "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", - "jsr:@std/path@^1.0.7": "1.0.8", - "jsr:@std/path@^1.0.8": "1.0.8", - "jsr:@std/path@~0.225.2": "0.225.2", - "jsr:@std/testing@*": "1.0.5", - "jsr:@std/testing@^1.0.5": "1.0.5", - "jsr:@ts-morph/bootstrap@0.24": "0.24.0", - "jsr:@ts-morph/common@0.24": "0.24.0", - "npm:@faker-js/faker@*": "9.2.0", "npm:@faker-js/faker@^9.2.0": "9.2.0", "npm:husky@^9.1.7": "9.1.7", "npm:lint-staged@15.2.10": "15.2.10", "npm:lint-staged@^15.2.10": "15.2.10" }, - "jsr": { - "@david/code-block-writer@13.0.3": { - "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" - }, - "@deno/cache-dir@0.10.3": { - "integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776", - "dependencies": [ - "jsr:@std/fmt@0.223", - "jsr:@std/fs@0.223", - "jsr:@std/io", - "jsr:@std/path@0.223" - ] - }, - "@deno/dnt@0.41.3": { - "integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2", - "dependencies": [ - "jsr:@david/code-block-writer", - "jsr:@deno/cache-dir", - "jsr:@std/fmt@1", - "jsr:@std/fs@1", - "jsr:@std/path@1", - "jsr:@ts-morph/bootstrap" - ] - }, - "@std/assert@0.223.0": { - "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" - }, - "@std/assert@0.226.0": { - "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" - }, - "@std/assert@1.0.8": { - "integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/async@1.0.8": { - "integrity": "c057c5211a0f1d12e7dcd111ab430091301b8d64b4250052a79d277383bc3ba7" - }, - "@std/bytes@0.223.0": { - "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" - }, - "@std/data-structures@1.0.4": { - "integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0" - }, - "@std/expect@1.0.8": { - "integrity": "27e40d8f3aefb372fc6a703fb0b69e34560e72a2f78705178babdffa00119a5f", - "dependencies": [ - "jsr:@std/assert@^1.0.8", - "jsr:@std/internal" - ] - }, - "@std/fmt@0.223.0": { - "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" - }, - "@std/fmt@1.0.3": { - "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f" - }, - "@std/fs@0.223.0": { - "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" - }, - "@std/fs@0.229.3": { - "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb", - "dependencies": [ - "jsr:@std/path@1.0.0-rc.1" - ] - }, - "@std/fs@1.0.5": { - "integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e", - "dependencies": [ - "jsr:@std/path@^1.0.7" - ] - }, - "@std/internal@1.0.5": { - "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" - }, - "@std/io@0.223.0": { - "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", - "dependencies": [ - "jsr:@std/assert@0.223", - "jsr:@std/bytes" - ] - }, - "@std/path@0.223.0": { - "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", - "dependencies": [ - "jsr:@std/assert@0.223" - ] - }, - "@std/path@0.225.2": { - "integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506", - "dependencies": [ - "jsr:@std/assert@0.226" - ] - }, - "@std/path@1.0.0-rc.1": { - "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" - }, - "@std/path@1.0.8": { - "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" - }, - "@std/testing@1.0.5": { - "integrity": "6e693cbec94c81a1ad3df668685c7ba8e20742bb10305bc7137faa5cf16d2ec4", - "dependencies": [ - "jsr:@std/assert@^1.0.8", - "jsr:@std/async", - "jsr:@std/data-structures", - "jsr:@std/fs@^1.0.5", - "jsr:@std/internal", - "jsr:@std/path@^1.0.8" - ] - }, - "@ts-morph/bootstrap@0.24.0": { - "integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060", - "dependencies": [ - "jsr:@ts-morph/common" - ] - }, - "@ts-morph/common@0.24.0": { - "integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3", - "dependencies": [ - "jsr:@std/fs@~0.229.3", - "jsr:@std/path@~0.225.2" - ] - } - }, "npm": { "@faker-js/faker@9.2.0": { "integrity": "sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg==" @@ -259,7 +103,8 @@ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==" }, "husky@9.1.7": { - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==" + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "bin": true }, "is-fullwidth-code-point@4.0.0": { "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==" @@ -295,7 +140,8 @@ "pidtree", "string-argv", "yaml" - ] + ], + "bin": true }, "listr2@8.2.5": { "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", @@ -365,7 +211,8 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, "pidtree@0.6.0": { - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==" + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "bin": true }, "restore-cursor@5.1.0": { "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", @@ -433,7 +280,8 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ "isexe" - ] + ], + "bin": true }, "wrap-ansi@9.0.0": { "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", @@ -444,7 +292,8 @@ ] }, "yaml@2.5.1": { - "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==" + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "bin": true } }, "workspace": { diff --git a/helpers/colorStringParsers.ts b/helpers/colorStringParsers.ts index 86d66a0..0ecbbc5 100644 --- a/helpers/colorStringParsers.ts +++ b/helpers/colorStringParsers.ts @@ -1,4 +1,4 @@ -import { RGBValues } from "../types/RGB.types.ts"; +import type { RGBValues } from "../types/RGB.types.ts"; import { extractRGBValuesFromHex, diff --git a/helpers/rgbConverters.test.ts b/helpers/rgbConverters.test.ts index 087a3ee..3c46b22 100644 --- a/helpers/rgbConverters.test.ts +++ b/helpers/rgbConverters.test.ts @@ -1,4 +1,4 @@ -import { Stub, stub } from "jsr:@std/testing/mock"; +import { type Stub, stub } from "jsr:@std/testing/mock"; import { expect, fn } from "@std/expect"; import { extractRGBValuesFromHex } from "./rgbConverters.ts"; diff --git a/helpers/rgbConverters.ts b/helpers/rgbConverters.ts index 8a76cd3..0d0b085 100644 --- a/helpers/rgbConverters.ts +++ b/helpers/rgbConverters.ts @@ -1,4 +1,4 @@ -import { RGBValues } from "../types/RGB.types.ts"; +import type { RGBValues } from "../types/RGB.types.ts"; /** * Converts a HEX color value to RGB diff --git a/modules/textContrastForBGColor.test.ts b/modules/textContrastForBGColor.test.ts index 5e88f1b..8ec3a36 100644 --- a/modules/textContrastForBGColor.test.ts +++ b/modules/textContrastForBGColor.test.ts @@ -1,10 +1,10 @@ -import { Stub, stub } from "@std/testing/mock"; +import { type Stub, stub } from "@std/testing/mock"; import { expect, fn } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; import { faker } from "npm:@faker-js/faker"; -import { ContrastrastOptions } from "../types/contrastrastOptionts.types.ts"; +import type { ContrastrastOptions } from "../types/contrastrastOptionts.types.ts"; import { textContrastForBGColor } from "../main.ts"; describe("# textContrastForBGColor", () => { diff --git a/modules/textContrastForBGColor.ts b/modules/textContrastForBGColor.ts index 565a321..420e94b 100644 --- a/modules/textContrastForBGColor.ts +++ b/modules/textContrastForBGColor.ts @@ -3,7 +3,7 @@ import { DEFAULT_CONTRASTRAST_OPTIONS, } from "../constants.ts"; import { getRGBFromColorString } from "../helpers/colorStringParsers.ts"; -import { ContrastrastOptions } from "../types/contrastrastOptionts.types.ts"; +import type { ContrastrastOptions } from "../types/contrastrastOptionts.types.ts"; /** * Recommends to use either `light` or `dark` text based on the From 9a5687a9cecef885856a41f5e467ecda92375aa4 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Mon, 11 Aug 2025 22:41:32 -0500 Subject: [PATCH 03/22] =?UTF-8?q?=F0=9F=9A=9A=20Rearrange=20code=20layout,?= =?UTF-8?q?=20clean=20up=20textContrast=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move legacy code to dedicated legacy folder, mark as deprecated * Rename some params, asset names to be more descriptive for their use cases * Update textContrast return values to give full breakdown of WCAG contrast metrics, clean up details section --- .gitignore | 2 ++ constants.ts | 6 ------ Contrastrast.ts => contrastrast.ts | 14 ++++++------- .../contrastrastOptions.types.ts | 0 .../textContrastForBGColor.test.ts | 2 +- {modules => legacy}/textContrastForBGColor.ts | 14 ++++++++----- main.ts | 4 +++- types/ContrastTypes.ts | 7 ++++++- utils/contrastRatio.ts | 2 +- utils/textContrast.ts | 21 +++++++++---------- 10 files changed, 39 insertions(+), 33 deletions(-) rename Contrastrast.ts => contrastrast.ts (93%) rename types/contrastrastOptionts.types.ts => legacy/contrastrastOptions.types.ts (100%) rename {modules => legacy}/textContrastForBGColor.test.ts (97%) rename {modules => legacy}/textContrastForBGColor.ts (81%) diff --git a/.gitignore b/.gitignore index 5286104..b0b00b8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ npm/ # Development and planning __SPECS__/ +demo.ts + diff --git a/constants.ts b/constants.ts index c5f33f5..a007f1a 100644 --- a/constants.ts +++ b/constants.ts @@ -1,4 +1,3 @@ -import type { ContrastrastOptions } from "./types/contrastrastOptionts.types.ts"; import type { TextSize, WCAGLevel } from "./types/ContrastTypes.ts"; // WC3 AERT brightness contrast threshold @@ -66,8 +65,3 @@ export const HSL_CONVERSION = { FULL_CIRCLE_DEGREES: 360, PERCENTAGE_MULTIPLIER: 100, } as const; - -export const DEFAULT_CONTRASTRAST_OPTIONS: ContrastrastOptions = { - fallbackOption: "dark", - throwErrorOnUnhandled: false, -}; diff --git a/Contrastrast.ts b/contrastrast.ts similarity index 93% rename from Contrastrast.ts rename to contrastrast.ts index 0446ec6..50624bc 100644 --- a/Contrastrast.ts +++ b/contrastrast.ts @@ -26,7 +26,7 @@ export class Contrastrast { this.rgb = getRGBFromColorString(colorString); } - // Factory Methods + // Parser/Creator Methods static fromHex = (hex: string): Contrastrast => { const normalizedHex = hex.startsWith("#") ? hex : `#${hex}`; return new Contrastrast(normalizedHex); @@ -61,7 +61,7 @@ export class Contrastrast { static parse = (colorString: string): Contrastrast => new Contrastrast(colorString); - // Conversion Methods + // Conversion & Output Methods toHex = (includeHash: boolean = true): string => { const toHex = (n: number) => { const hex = Math.round(n).toString(16); @@ -162,16 +162,16 @@ export class Contrastrast { contrastRatio(this, color); textContrast = ( - otherColor: Contrastrast | string, + comparisonColor: Contrastrast | string, role: "foreground" | "background" = "background", options: ContrastOptions = {}, ): number | ContrastResult => { if (role === "background") { - // Current color is background, otherColor is foreground - return textContrast(otherColor, this, options); + // Current color is background, comparisonColor is foreground (text color) + return textContrast(comparisonColor, this, options); } else { - // Current color is foreground, otherColor is background - return textContrast(this, otherColor, options); + // Current color is foreground (text color), comparisonColor is background + return textContrast(this, comparisonColor, options); } }; diff --git a/types/contrastrastOptionts.types.ts b/legacy/contrastrastOptions.types.ts similarity index 100% rename from types/contrastrastOptionts.types.ts rename to legacy/contrastrastOptions.types.ts diff --git a/modules/textContrastForBGColor.test.ts b/legacy/textContrastForBGColor.test.ts similarity index 97% rename from modules/textContrastForBGColor.test.ts rename to legacy/textContrastForBGColor.test.ts index 8ec3a36..ed0efaa 100644 --- a/modules/textContrastForBGColor.test.ts +++ b/legacy/textContrastForBGColor.test.ts @@ -4,8 +4,8 @@ import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; import { faker } from "npm:@faker-js/faker"; -import type { ContrastrastOptions } from "../types/contrastrastOptionts.types.ts"; import { textContrastForBGColor } from "../main.ts"; +import type { ContrastrastOptions } from "./contrastrastOptions.types.ts"; describe("# textContrastForBGColor", () => { const consoleErrorSpy = fn(); diff --git a/modules/textContrastForBGColor.ts b/legacy/textContrastForBGColor.ts similarity index 81% rename from modules/textContrastForBGColor.ts rename to legacy/textContrastForBGColor.ts index 420e94b..b83e64c 100644 --- a/modules/textContrastForBGColor.ts +++ b/legacy/textContrastForBGColor.ts @@ -1,9 +1,11 @@ -import { - CONTRAST_THRESHOLD, - DEFAULT_CONTRASTRAST_OPTIONS, -} from "../constants.ts"; +import { CONTRAST_THRESHOLD } from "../constants.ts"; import { getRGBFromColorString } from "../helpers/colorStringParsers.ts"; -import type { ContrastrastOptions } from "../types/contrastrastOptionts.types.ts"; +import type { ContrastrastOptions } from "./contrastrastOptions.types.ts"; + +const DEFAULT_CONTRASTRAST_OPTIONS: ContrastrastOptions = { + fallbackOption: "dark", + throwErrorOnUnhandled: false, +}; /** * Recommends to use either `light` or `dark` text based on the @@ -11,6 +13,8 @@ import type { ContrastrastOptions } from "../types/contrastrastOptionts.types.ts * * Color string can be HEX, RGB, or HSL * + * @deprecated This method will go away in v2, we recommend switching to `Contrastrast(color).textContrast(bgColor)` for a more comprehensive and accurate comparison + * * @param {String} bgColorString Color string of the background. Can be HEX, RGB, or HSL * @param {ContrastrastOptions} options (Optional) Partial collection `ContrastrastOptions` that you wish you apply * @param {"dark"|"light"} [options.fallbackOption="dark"] Fallback color recommendation, returns on error or unparsable color string. Defaults to `dark` diff --git a/main.ts b/main.ts index cd119c0..5eb4f40 100644 --- a/main.ts +++ b/main.ts @@ -1 +1,3 @@ -export { textContrastForBGColor } from "./modules/textContrastForBGColor.ts"; +export { Contrastrast } from "./contrastrast.ts"; + +export { textContrastForBGColor } from "./legacy/textContrastForBGColor.ts"; diff --git a/types/ContrastTypes.ts b/types/ContrastTypes.ts index de846d7..f5da836 100644 --- a/types/ContrastTypes.ts +++ b/types/ContrastTypes.ts @@ -4,7 +4,12 @@ export type TextSize = "normal" | "large"; export type ContrastResult = { ratio: number; - passes: boolean; + passes: { + AA_NORMAL: boolean; + AA_LARGE: boolean; + AAA_NORMAL: boolean; + AAA_LARGE: boolean; + }; details: { required: number; actual: number; diff --git a/utils/contrastRatio.ts b/utils/contrastRatio.ts index 5400d1c..347e414 100644 --- a/utils/contrastRatio.ts +++ b/utils/contrastRatio.ts @@ -1,4 +1,4 @@ -import { Contrastrast } from "../Contrastrast.ts"; +import { Contrastrast } from "../contrastrast.ts"; import { CONTRAST_RATIO } from "../constants.ts"; /** diff --git a/utils/textContrast.ts b/utils/textContrast.ts index a74f54b..5a3df8f 100644 --- a/utils/textContrast.ts +++ b/utils/textContrast.ts @@ -1,4 +1,4 @@ -import type { Contrastrast } from "../Contrastrast.ts"; +import type { Contrastrast } from "../contrastrast.ts"; import type { ContrastOptions, ContrastResult, @@ -10,7 +10,8 @@ import { contrastRatio } from "./contrastRatio.ts"; * Analyze text contrast between foreground and background colors * @param foreground Foreground color (text color) * @param background Background color - * @param options Configuration options for WCAG compliance checking + * @param options (Optional) Configuration options for WCAG compliance checking + * @param options.returnDetails When `true` returns the ratio and full WCAG breakdown, when `false` returns only the ratio as a `number` * @returns Contrast ratio number or detailed ContrastResult object */ export const textContrast = ( @@ -18,7 +19,7 @@ export const textContrast = ( background: Contrastrast | string, options: ContrastOptions = {}, ): number | ContrastResult => { - const { level = "AA", textSize = "normal", returnDetails = false } = options; + const { returnDetails = false } = options; const ratio = contrastRatio(foreground, background); @@ -26,17 +27,15 @@ export const textContrast = ( return ratio; } - const required = WCAG_LEVELS[level][textSize]; - const passes = ratio >= required; + const passes = { + AA_NORMAL: ratio >= WCAG_LEVELS["AA"]["normal"], + AA_LARGE: ratio >= WCAG_LEVELS["AA"]["large"], + AAA_NORMAL: ratio >= WCAG_LEVELS["AAA"]["normal"], + AAA_LARGE: ratio >= WCAG_LEVELS["AAA"]["large"], + }; return { ratio, passes, - details: { - required, - actual: ratio, - level, - textSize, - }, }; }; From ddac686cb672d17111989bb7f47d0be184cd926a Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Mon, 11 Aug 2025 22:48:00 -0500 Subject: [PATCH 04/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move colors to standalone file * Move WCAG types to standalone file * Move textContrast function options inside textContrast util file --- constants.ts | 8 +++++--- contrastrast.ts | 26 ++++++++++++-------------- types/Colors.types.ts | 11 +++++++++++ types/ContrastTypes.ts | 31 ------------------------------- types/RGB.types.ts | 5 ----- types/WCAG.types.ts | 3 +++ utils/textContrast.ts | 23 ++++++++++++++++++----- 7 files changed, 49 insertions(+), 58 deletions(-) create mode 100644 types/Colors.types.ts delete mode 100644 types/ContrastTypes.ts delete mode 100644 types/RGB.types.ts create mode 100644 types/WCAG.types.ts diff --git a/constants.ts b/constants.ts index a007f1a..bd5d99a 100644 --- a/constants.ts +++ b/constants.ts @@ -1,6 +1,5 @@ -import type { TextSize, WCAGLevel } from "./types/ContrastTypes.ts"; +import type { WCAGContrastLevel, WCAGTextSize } from "./types/WCAG.types.ts"; -// WC3 AERT brightness contrast threshold // Source: https://www.w3.org/TR/AERT/#color-contrast export const CONTRAST_THRESHOLD = 124; @@ -40,7 +39,10 @@ export const CONTRAST_RATIO = { // WCAG 2.1 minimum contrast thresholds // Source: https://www.w3.org/TR/WCAG21/#contrast-minimum // Source: https://www.w3.org/TR/WCAG21/#contrast-enhanced -export const WCAG_LEVELS: Record> = { +export const WCAG_LEVELS: Record< + WCAGContrastLevel, + Record +> = { AA: { normal: 4.5, large: 3.0, diff --git a/contrastrast.ts b/contrastrast.ts index 50624bc..304acd8 100644 --- a/contrastrast.ts +++ b/contrastrast.ts @@ -1,11 +1,3 @@ -import type { RGBValues } from "./types/RGB.types.ts"; -import type { - ContrastOptions, - ContrastResult, - HSLValues, - TextSize, - WCAGLevel, -} from "./types/ContrastTypes.ts"; import { getRGBFromColorString } from "./helpers/colorStringParsers.ts"; import { BRIGHTNESS_COEFFICIENTS, @@ -17,7 +9,13 @@ import { WCAG_LEVELS, } from "./constants.ts"; import { contrastRatio } from "./utils/contrastRatio.ts"; -import { textContrast } from "./utils/textContrast.ts"; +import { + type ContrastOptions, + type ContrastResult, + textContrast, +} from "./utils/textContrast.ts"; +import type { HSLValues, RGBValues } from "./types/Colors.types.ts"; +import type { WCAGContrastLevel, WCAGTextSize } from "./types/WCAG.types.ts"; export class Contrastrast { private readonly rgb: RGBValues; @@ -177,13 +175,13 @@ export class Contrastrast { // WCAG Compliance Helper meetsWCAG = ( - otherColor: Contrastrast | string, + comparisonColor: Contrastrast | string, role: "foreground" | "background", - level: WCAGLevel, - textSize: TextSize = "normal", + targetWcagLevel: WCAGContrastLevel, + textSize: WCAGTextSize = "normal", ): boolean => { - const ratio = this.textContrast(otherColor, role); - const required = WCAG_LEVELS[level][textSize]; + const ratio = this.textContrast(comparisonColor, role); + const required = WCAG_LEVELS[targetWcagLevel][textSize]; return (typeof ratio === "number" ? ratio : ratio.ratio) >= required; }; diff --git a/types/Colors.types.ts b/types/Colors.types.ts new file mode 100644 index 0000000..f1edb50 --- /dev/null +++ b/types/Colors.types.ts @@ -0,0 +1,11 @@ +export type RGBValues = { + r: number; + g: number; + b: number; +}; + +export type HSLValues = { + h: number; + s: number; + l: number; +}; diff --git a/types/ContrastTypes.ts b/types/ContrastTypes.ts deleted file mode 100644 index f5da836..0000000 --- a/types/ContrastTypes.ts +++ /dev/null @@ -1,31 +0,0 @@ -export type WCAGLevel = "AA" | "AAA"; - -export type TextSize = "normal" | "large"; - -export type ContrastResult = { - ratio: number; - passes: { - AA_NORMAL: boolean; - AA_LARGE: boolean; - AAA_NORMAL: boolean; - AAA_LARGE: boolean; - }; - details: { - required: number; - actual: number; - level: WCAGLevel; - textSize: TextSize; - }; -}; - -export type ContrastOptions = { - level?: WCAGLevel; - textSize?: TextSize; - returnDetails?: boolean; -}; - -export type HSLValues = { - h: number; - s: number; - l: number; -}; diff --git a/types/RGB.types.ts b/types/RGB.types.ts deleted file mode 100644 index b597abd..0000000 --- a/types/RGB.types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type RGBValues = { - r: number; - g: number; - b: number; -}; diff --git a/types/WCAG.types.ts b/types/WCAG.types.ts new file mode 100644 index 0000000..ff934e6 --- /dev/null +++ b/types/WCAG.types.ts @@ -0,0 +1,3 @@ +export type WCAGContrastLevel = "AA" | "AAA"; + +export type WCAGTextSize = "normal" | "large"; diff --git a/utils/textContrast.ts b/utils/textContrast.ts index 5a3df8f..039ae8f 100644 --- a/utils/textContrast.ts +++ b/utils/textContrast.ts @@ -1,17 +1,30 @@ import type { Contrastrast } from "../contrastrast.ts"; -import type { - ContrastOptions, - ContrastResult, -} from "../types/ContrastTypes.ts"; import { WCAG_LEVELS } from "../constants.ts"; import { contrastRatio } from "./contrastRatio.ts"; +import type { WCAGContrastLevel, WCAGTextSize } from "../types/WCAG.types.ts"; + +export type ContrastResult = { + ratio: number; + passes: { + AA_NORMAL: boolean; + AA_LARGE: boolean; + AAA_NORMAL: boolean; + AAA_LARGE: boolean; + }; +}; + +export type ContrastOptions = { + level?: WCAGContrastLevel; + textSize?: WCAGTextSize; + returnDetails?: boolean; +}; /** * Analyze text contrast between foreground and background colors * @param foreground Foreground color (text color) * @param background Background color * @param options (Optional) Configuration options for WCAG compliance checking - * @param options.returnDetails When `true` returns the ratio and full WCAG breakdown, when `false` returns only the ratio as a `number` + * @param options.returnDetails When `true` returns the ratio and full WCAG contrast breakdown, when `false` returns only the ratio as a `number` * @returns Contrast ratio number or detailed ContrastResult object */ export const textContrast = ( From 7e012d1f59024ca1cb80ce017ea28c9c9114265d Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Tue, 12 Aug 2025 00:17:09 -0500 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=8E=A8=20Add=20overloads=20for=20ty?= =?UTF-8?q?pes,=20write=20util=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add overloads for textContrast method for better type returns * Write tests for all util functions * Migrates old tests to new deno-like test format * Fix deno.json excludes arrays to stop erroring on the npm script & folder --- contrastrast.ts | 66 ++++- deno.json | 14 ++ deno.lock | 161 ++++++++++++ helpers/colorStringParsers.ts | 2 +- ...ers.test.ts => colorStringParsers_test.ts} | 0 helpers/rgbConverters.ts | 2 +- ...nverters.test.ts => rgbConverters_test.ts} | 0 ...test.ts => textContrastForBGColor_test.ts} | 0 utils/contrastRatio_test.ts | 118 +++++++++ utils/textContrast.ts | 72 +++++- utils/textContrast_test.ts | 232 ++++++++++++++++++ 11 files changed, 649 insertions(+), 18 deletions(-) rename helpers/{colorStringParsers.test.ts => colorStringParsers_test.ts} (100%) rename helpers/{rgbConverters.test.ts => rgbConverters_test.ts} (100%) rename legacy/{textContrastForBGColor.test.ts => textContrastForBGColor_test.ts} (100%) create mode 100644 utils/contrastRatio_test.ts create mode 100644 utils/textContrast_test.ts diff --git a/contrastrast.ts b/contrastrast.ts index 304acd8..015baf9 100644 --- a/contrastrast.ts +++ b/contrastrast.ts @@ -159,11 +159,69 @@ export class Contrastrast { contrastRatio = (color: Contrastrast | string): number => contrastRatio(this, color); - textContrast = ( + /** + * Calculate the contrast ratio between this color and another color + * @param comparisonColor Color to compare against - accepts hex, rgb, hsl strings or Contrastrast instance + * @param role Role of this color instance in the contrast calculation + * @returns Contrast ratio as a number (1:1 to 21:1) + * @example + * ```typescript + * const bgColor = new Contrastrast("#1a73e8"); + * const ratio = bgColor.textContrast("#ffffff"); // 4.5 (current as background, white as foreground) + * const ratio2 = bgColor.textContrast("#ffffff", "foreground"); // 4.5 (current as foreground, white as background) + * ``` + */ + textContrast( + comparisonColor: Contrastrast | string, + role?: "foreground" | "background", + ): number; + + /** + * Analyze text contrast with detailed WCAG compliance results + * @param comparisonColor Color to compare against - accepts hex, rgb, hsl strings or Contrastrast instance + * @param role Role of this color instance in the contrast calculation + * @param options Configuration with returnDetails: true for detailed analysis + * @returns Detailed contrast analysis with WCAG compliance breakdown + * @example + * ```typescript + * const bgColor = new Contrastrast("#1a73e8"); + * const result = bgColor.textContrast("#ffffff", "background", { returnDetails: true }); + * // { + * // ratio: 4.5, + * // passes: { + * // AA_NORMAL: true, // 4.5 >= 4.5 + * // AA_LARGE: true, // 4.5 >= 3.0 + * // AAA_NORMAL: false, // 4.5 < 7.0 + * // AAA_LARGE: true // 4.5 >= 4.5 + * // } + * // } + * ``` + */ + textContrast( + comparisonColor: Contrastrast | string, + role: "foreground" | "background", + options: { returnDetails: true }, + ): ContrastResult; + + /** + * Calculate the contrast ratio between this color and another color + * @param comparisonColor Color to compare against - accepts hex, rgb, hsl strings or Contrastrast instance + * @param role Role of this color instance in the contrast calculation + * @param options Configuration with returnDetails: false (default) for simple ratio + * @returns Contrast ratio as a number (1:1 to 21:1) + */ + textContrast( + comparisonColor: Contrastrast | string, + role?: "foreground" | "background", + options?: ContrastOptions, + ): number; + + // Implementation + textContrast( comparisonColor: Contrastrast | string, role: "foreground" | "background" = "background", options: ContrastOptions = {}, - ): number | ContrastResult => { + ): number | ContrastResult { if (role === "background") { // Current color is background, comparisonColor is foreground (text color) return textContrast(comparisonColor, this, options); @@ -171,7 +229,7 @@ export class Contrastrast { // Current color is foreground (text color), comparisonColor is background return textContrast(this, comparisonColor, options); } - }; + } // WCAG Compliance Helper meetsWCAG = ( @@ -182,7 +240,7 @@ export class Contrastrast { ): boolean => { const ratio = this.textContrast(comparisonColor, role); const required = WCAG_LEVELS[targetWcagLevel][textSize]; - return (typeof ratio === "number" ? ratio : ratio.ratio) >= required; + return ratio >= required; }; // Utility Methods diff --git a/deno.json b/deno.json index 35d7776..156eef1 100644 --- a/deno.json +++ b/deno.json @@ -30,6 +30,20 @@ "prepare": "husky", "build:npm": "deno run -A scripts/build_npm.ts" }, + "exclude": [ + "npm/", + "__SPECS__/", + "scripts/", + "node_modules/" + ], + "test": { + "exclude": [ + "npm/", + "__SPECS__/", + "scripts/", + "node_modules/" + ] + }, "imports": { "@deno/dnt": "jsr:@deno/dnt@^0.41.3", "@faker-js/faker": "npm:@faker-js/faker@^9.2.0", diff --git a/deno.lock b/deno.lock index af50f1c..07a0cb3 100644 --- a/deno.lock +++ b/deno.lock @@ -1,11 +1,172 @@ { "version": "5", "specifiers": { + "jsr:@david/code-block-writer@^13.0.2": "13.0.3", + "jsr:@deno/cache-dir@~0.10.3": "0.10.3", + "jsr:@deno/dnt@~0.41.3": "0.41.3", + "jsr:@deno/graph@~0.73.1": "0.73.1", + "jsr:@std/assert@0.223": "0.223.0", + "jsr:@std/assert@0.226": "0.226.0", + "jsr:@std/assert@^1.0.8": "1.0.8", + "jsr:@std/async@^1.0.8": "1.0.8", + "jsr:@std/bytes@0.223": "0.223.0", + "jsr:@std/data-structures@^1.0.4": "1.0.4", + "jsr:@std/expect@*": "1.0.8", + "jsr:@std/expect@^1.0.8": "1.0.8", + "jsr:@std/fmt@0.223": "0.223.0", + "jsr:@std/fmt@1": "1.0.3", + "jsr:@std/fs@0.223": "0.223.0", + "jsr:@std/fs@1": "1.0.5", + "jsr:@std/fs@^1.0.5": "1.0.5", + "jsr:@std/fs@~0.229.3": "0.229.3", + "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/io@0.223": "0.223.0", + "jsr:@std/path@0.223": "0.223.0", + "jsr:@std/path@1": "1.0.8", + "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", + "jsr:@std/path@^1.0.7": "1.0.8", + "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/path@~0.225.2": "0.225.2", + "jsr:@std/testing@*": "1.0.5", + "jsr:@std/testing@^1.0.5": "1.0.5", + "jsr:@ts-morph/bootstrap@0.24": "0.24.0", + "jsr:@ts-morph/common@0.24": "0.24.0", + "npm:@faker-js/faker@*": "9.2.0", "npm:@faker-js/faker@^9.2.0": "9.2.0", "npm:husky@^9.1.7": "9.1.7", "npm:lint-staged@15.2.10": "15.2.10", "npm:lint-staged@^15.2.10": "15.2.10" }, + "jsr": { + "@david/code-block-writer@13.0.3": { + "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" + }, + "@deno/cache-dir@0.10.3": { + "integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776", + "dependencies": [ + "jsr:@deno/graph", + "jsr:@std/fmt@0.223", + "jsr:@std/fs@0.223", + "jsr:@std/io", + "jsr:@std/path@0.223" + ] + }, + "@deno/dnt@0.41.3": { + "integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2", + "dependencies": [ + "jsr:@david/code-block-writer", + "jsr:@deno/cache-dir", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", + "jsr:@std/path@1", + "jsr:@ts-morph/bootstrap" + ] + }, + "@deno/graph@0.73.1": { + "integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41" + }, + "@std/assert@0.223.0": { + "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" + }, + "@std/assert@0.226.0": { + "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" + }, + "@std/assert@1.0.8": { + "integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/async@1.0.8": { + "integrity": "c057c5211a0f1d12e7dcd111ab430091301b8d64b4250052a79d277383bc3ba7" + }, + "@std/bytes@0.223.0": { + "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" + }, + "@std/data-structures@1.0.4": { + "integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0" + }, + "@std/expect@1.0.8": { + "integrity": "27e40d8f3aefb372fc6a703fb0b69e34560e72a2f78705178babdffa00119a5f", + "dependencies": [ + "jsr:@std/assert@^1.0.8", + "jsr:@std/internal" + ] + }, + "@std/fmt@0.223.0": { + "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" + }, + "@std/fmt@1.0.3": { + "integrity": "97765c16aa32245ff4e2204ecf7d8562496a3cb8592340a80e7e554e0bb9149f" + }, + "@std/fs@0.223.0": { + "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" + }, + "@std/fs@0.229.3": { + "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb", + "dependencies": [ + "jsr:@std/path@1.0.0-rc.1" + ] + }, + "@std/fs@1.0.5": { + "integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e", + "dependencies": [ + "jsr:@std/path@^1.0.7" + ] + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + }, + "@std/io@0.223.0": { + "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", + "dependencies": [ + "jsr:@std/assert@0.223", + "jsr:@std/bytes" + ] + }, + "@std/path@0.223.0": { + "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", + "dependencies": [ + "jsr:@std/assert@0.223" + ] + }, + "@std/path@0.225.2": { + "integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506", + "dependencies": [ + "jsr:@std/assert@0.226" + ] + }, + "@std/path@1.0.0-rc.1": { + "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + }, + "@std/testing@1.0.5": { + "integrity": "6e693cbec94c81a1ad3df668685c7ba8e20742bb10305bc7137faa5cf16d2ec4", + "dependencies": [ + "jsr:@std/assert@^1.0.8", + "jsr:@std/async", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.5", + "jsr:@std/internal", + "jsr:@std/path@^1.0.8" + ] + }, + "@ts-morph/bootstrap@0.24.0": { + "integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060", + "dependencies": [ + "jsr:@ts-morph/common" + ] + }, + "@ts-morph/common@0.24.0": { + "integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3", + "dependencies": [ + "jsr:@std/fs@~0.229.3", + "jsr:@std/path@~0.225.2" + ] + } + }, "npm": { "@faker-js/faker@9.2.0": { "integrity": "sha512-ulqQu4KMr1/sTFIYvqSdegHT8NIkt66tFAkugGnHA+1WAfEn6hMzNR+svjXGFRVLnapxvej67Z/LwchFrnLBUg==" diff --git a/helpers/colorStringParsers.ts b/helpers/colorStringParsers.ts index 0ecbbc5..7ef460c 100644 --- a/helpers/colorStringParsers.ts +++ b/helpers/colorStringParsers.ts @@ -1,4 +1,4 @@ -import type { RGBValues } from "../types/RGB.types.ts"; +import type { RGBValues } from "../types/Colors.types.ts"; import { extractRGBValuesFromHex, diff --git a/helpers/colorStringParsers.test.ts b/helpers/colorStringParsers_test.ts similarity index 100% rename from helpers/colorStringParsers.test.ts rename to helpers/colorStringParsers_test.ts diff --git a/helpers/rgbConverters.ts b/helpers/rgbConverters.ts index 0d0b085..07017ff 100644 --- a/helpers/rgbConverters.ts +++ b/helpers/rgbConverters.ts @@ -1,4 +1,4 @@ -import type { RGBValues } from "../types/RGB.types.ts"; +import type { RGBValues } from "../types/Colors.types.ts"; /** * Converts a HEX color value to RGB diff --git a/helpers/rgbConverters.test.ts b/helpers/rgbConverters_test.ts similarity index 100% rename from helpers/rgbConverters.test.ts rename to helpers/rgbConverters_test.ts diff --git a/legacy/textContrastForBGColor.test.ts b/legacy/textContrastForBGColor_test.ts similarity index 100% rename from legacy/textContrastForBGColor.test.ts rename to legacy/textContrastForBGColor_test.ts diff --git a/utils/contrastRatio_test.ts b/utils/contrastRatio_test.ts new file mode 100644 index 0000000..d1eedb7 --- /dev/null +++ b/utils/contrastRatio_test.ts @@ -0,0 +1,118 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { contrastRatio } from "./contrastRatio.ts"; +import { Contrastrast } from "../contrastrast.ts"; + +describe("# contrastRatio", () => { + describe("## basic contrast calculations", () => { + it("calculates contrast ratio between black and white", () => { + const ratio = contrastRatio("#000000", "#ffffff"); + expect(ratio).toBeCloseTo(21, 1); + }); + + it("calculates contrast ratio between white and black (order independence)", () => { + const ratio = contrastRatio("#ffffff", "#000000"); + expect(ratio).toBeCloseTo(21, 1); + }); + + it("returns 1 for identical colors", () => { + const ratio = contrastRatio("#ff0000", "#ff0000"); + expect(ratio).toBeCloseTo(1, 1); + }); + + it("handles identical colors with different formats", () => { + const ratio = contrastRatio("#ff0000", "rgb(255, 0, 0)"); + expect(ratio).toBeCloseTo(1, 1); + }); + }); + + describe("## WCAG reference values", () => { + it("matches known Google Blue contrast ratio", () => { + // Google Blue (#1a73e8) on white background + const ratio = contrastRatio("#1a73e8", "#ffffff"); + expect(ratio).toBeCloseTo(4.5, 1); + }); + + it("matches known red contrast ratio", () => { + // Pure red on white background + const ratio = contrastRatio("#ff0000", "#ffffff"); + expect(ratio).toBeCloseTo(3.998, 1); + }); + + it("handles gray combinations", () => { + // Light gray on white + const ratio = contrastRatio("#cccccc", "#ffffff"); + expect(ratio).toBeCloseTo(1.61, 1); + }); + }); + + describe("## input format flexibility", () => { + it("accepts Contrastrast instances", () => { + const color1 = new Contrastrast("#000000"); + const color2 = new Contrastrast("#ffffff"); + const ratio = contrastRatio(color1, color2); + expect(ratio).toBeCloseTo(21, 1); + }); + + it("accepts mixed input types", () => { + const color1 = new Contrastrast("#000000"); + const ratio = contrastRatio(color1, "#ffffff"); + expect(ratio).toBeCloseTo(21, 1); + }); + + it("handles different color formats", () => { + const ratioHex = contrastRatio("#ff0000", "#ffffff"); + const ratioRgb = contrastRatio("rgb(255, 0, 0)", "#ffffff"); + const ratioHsl = contrastRatio("hsl(0, 100%, 50%)", "#ffffff"); + + expect(ratioHex).toBeCloseTo(ratioRgb, 2); + expect(ratioRgb).toBeCloseTo(ratioHsl, 2); + }); + }); + + describe("## edge cases", () => { + it("handles very dark colors", () => { + const ratio = contrastRatio("#010101", "#000000"); + expect(ratio).toBeGreaterThan(1); + expect(ratio).toBeLessThan(1.1); + }); + + it("handles very light colors", () => { + const ratio = contrastRatio("#fefefe", "#ffffff"); + expect(ratio).toBeGreaterThan(1); + expect(ratio).toBeLessThan(1.1); + }); + + it("handles mid-tone combinations", () => { + const ratio = contrastRatio("#808080", "#404040"); + expect(ratio).toBeGreaterThan(1); + expect(ratio).toBeLessThan(21); + }); + }); + + describe("## WCAG compliance thresholds", () => { + it("identifies AA normal text compliance", () => { + // Should pass AA normal (4.5:1) + const ratio = contrastRatio("#1a73e8", "#ffffff"); + expect(ratio).toBeGreaterThan(4.5); + }); + + it("identifies AA large text compliance", () => { + // Should pass AA large (3:1) + const ratio = contrastRatio("#ff0000", "#ffffff"); + expect(ratio).toBeGreaterThan(3.0); + }); + + it("identifies AAA compliance", () => { + // Should pass AAA (7:1) + const ratio = contrastRatio("#000080", "#ffffff"); + expect(ratio).toBeGreaterThan(7.0); + }); + + it("identifies non-compliant combinations", () => { + // Should fail AA normal + const ratio = contrastRatio("#cccccc", "#ffffff"); + expect(ratio).toBeLessThan(4.5); + }); + }); +}); diff --git a/utils/textContrast.ts b/utils/textContrast.ts index 039ae8f..5bfc860 100644 --- a/utils/textContrast.ts +++ b/utils/textContrast.ts @@ -1,7 +1,6 @@ import type { Contrastrast } from "../contrastrast.ts"; import { WCAG_LEVELS } from "../constants.ts"; import { contrastRatio } from "./contrastRatio.ts"; -import type { WCAGContrastLevel, WCAGTextSize } from "../types/WCAG.types.ts"; export type ContrastResult = { ratio: number; @@ -14,24 +13,73 @@ export type ContrastResult = { }; export type ContrastOptions = { - level?: WCAGContrastLevel; - textSize?: WCAGTextSize; returnDetails?: boolean; }; /** - * Analyze text contrast between foreground and background colors - * @param foreground Foreground color (text color) - * @param background Background color - * @param options (Optional) Configuration options for WCAG compliance checking - * @param options.returnDetails When `true` returns the ratio and full WCAG contrast breakdown, when `false` returns only the ratio as a `number` - * @returns Contrast ratio number or detailed ContrastResult object + * Calculate the contrast ratio between foreground and background colors + * @param foreground Foreground color (text color) - accepts hex, rgb, hsl strings or Contrastrast instance + * @param background Background color - accepts hex, rgb, hsl strings or Contrastrast instance + * @returns Contrast ratio as a number (1:1 to 21:1) + * @example + * ```typescript + * const ratio = textContrast("#000000", "#ffffff"); // 21 + * const ratio2 = textContrast("rgb(255, 0, 0)", "#fff"); // 3.998 + * ``` */ -export const textContrast = ( +export function textContrast( + foreground: Contrastrast | string, + background: Contrastrast | string, +): number; + +/** + * Analyze text contrast with detailed WCAG compliance results + * @param foreground Foreground color (text color) - accepts hex, rgb, hsl strings or Contrastrast instance + * @param background Background color - accepts hex, rgb, hsl strings or Contrastrast instance + * @param options Configuration with returnDetails: true for detailed analysis + * @returns Detailed contrast analysis with WCAG compliance breakdown + * @example + * ```typescript + * const result = textContrast("#1a73e8", "#ffffff", { returnDetails: true }); + * // { + * // ratio: 4.5, + * // passes: { + * // AA_NORMAL: true, // 4.5 >= 4.5 + * // AA_LARGE: true, // 4.5 >= 3.0 + * // AAA_NORMAL: false, // 4.5 < 7.0 + * // AAA_LARGE: true // 4.5 >= 4.5 + * // } + * // } + * ``` + */ +export function textContrast( + foreground: Contrastrast | string, + background: Contrastrast | string, + options: { returnDetails: true }, +): ContrastResult; + +/** + * Calculate the contrast ratio between foreground and background colors + * @param foreground Foreground color (text color) - accepts hex, rgb, hsl strings or Contrastrast instance + * @param background Background color - accepts hex, rgb, hsl strings or Contrastrast instance + * @param options Configuration with returnDetails: false (default) for simple ratio + * @returns Contrast ratio as a number (1:1 to 21:1) + * @example + * ```typescript + * const ratio = textContrast("#000", "#fff", { returnDetails: false }); // 21 + * ``` + */ +export function textContrast( + foreground: Contrastrast | string, + background: Contrastrast | string, + options?: ContrastOptions, +): number; + +export function textContrast( foreground: Contrastrast | string, background: Contrastrast | string, options: ContrastOptions = {}, -): number | ContrastResult => { +): number | ContrastResult { const { returnDetails = false } = options; const ratio = contrastRatio(foreground, background); @@ -51,4 +99,4 @@ export const textContrast = ( ratio, passes, }; -}; +} diff --git a/utils/textContrast_test.ts b/utils/textContrast_test.ts new file mode 100644 index 0000000..7433bac --- /dev/null +++ b/utils/textContrast_test.ts @@ -0,0 +1,232 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { textContrast } from "./textContrast.ts"; +import { contrastRatio } from "./contrastRatio.ts"; +import { Contrastrast } from "../contrastrast.ts"; + +describe("# textContrast", () => { + describe("## basic contrast calculations", () => { + it("returns numeric ratio by default", () => { + const result = textContrast("#000000", "#ffffff"); + expect(typeof result).toBe("number"); + expect(result).toBeCloseTo(21, 1); + }); + + it("returns same value as contrastRatio utility", () => { + const textResult = textContrast("#ff0000", "#ffffff"); + const ratioResult = contrastRatio("#ff0000", "#ffffff"); + expect(textResult).toBe(ratioResult); + }); + + it("handles order independence", () => { + const result1 = textContrast("#000000", "#ffffff"); + const result2 = textContrast("#ffffff", "#000000"); + expect(result1).toBe(result2); + }); + }); + + describe("## detailed results", () => { + it("returns detailed results when returnDetails is true", () => { + const result = textContrast("#000000", "#ffffff", { + returnDetails: true, + }); + + expect(typeof result).toBe("object"); + expect(result).toHaveProperty("ratio"); + expect(result).toHaveProperty("passes"); + expect(result.ratio).toBeCloseTo(21, 1); + }); + + it("includes all WCAG compliance checks in passes object", () => { + const result = textContrast("#1a73e8", "#ffffff", { + returnDetails: true, + }); + + expect(result.passes).toHaveProperty("AA_NORMAL"); + expect(result.passes).toHaveProperty("AA_LARGE"); + expect(result.passes).toHaveProperty("AAA_NORMAL"); + expect(result.passes).toHaveProperty("AAA_LARGE"); + }); + + it("correctly identifies passing WCAG combinations", () => { + // Black on white should pass all WCAG levels + const result = textContrast("#000000", "#ffffff", { + returnDetails: true, + }); + + expect(result.passes.AA_NORMAL).toBe(true); + expect(result.passes.AA_LARGE).toBe(true); + expect(result.passes.AAA_NORMAL).toBe(true); + expect(result.passes.AAA_LARGE).toBe(true); + }); + + it("correctly identifies failing WCAG combinations", () => { + // Light gray on white should fail all WCAG levels + const result = textContrast("#cccccc", "#ffffff", { + returnDetails: true, + }); + + expect(result.passes.AA_NORMAL).toBe(false); + expect(result.passes.AA_LARGE).toBe(false); + expect(result.passes.AAA_NORMAL).toBe(false); + expect(result.passes.AAA_LARGE).toBe(false); + }); + + it("correctly identifies partial WCAG compliance", () => { + // Medium gray should pass AA but not AAA normal + const result = textContrast("#666666", "#ffffff", { + returnDetails: true, + }); + + // Should pass AA (both normal and large) and AAA large, but not AAA normal + expect(result.passes.AA_NORMAL).toBe(true); // 5.74 > 4.5 + expect(result.passes.AA_LARGE).toBe(true); // 5.74 > 3.0 + expect(result.passes.AAA_NORMAL).toBe(false); // 5.74 < 7.0 + expect(result.passes.AAA_LARGE).toBe(true); // 5.74 > 4.5 + }); + }); + + describe("## input format flexibility", () => { + it("accepts Contrastrast instances", () => { + const color1 = new Contrastrast("#000000"); + const color2 = new Contrastrast("#ffffff"); + const result = textContrast(color1, color2); + expect(result).toBeCloseTo(21, 1); + }); + + it("accepts mixed input types", () => { + const color1 = new Contrastrast("#000000"); + const result = textContrast(color1, "#ffffff"); + expect(result).toBeCloseTo(21, 1); + }); + + it("handles different color formats consistently", () => { + const resultHex = textContrast("#ff0000", "#ffffff"); + const resultRgb = textContrast("rgb(255, 0, 0)", "#ffffff"); + const resultHsl = textContrast("hsl(0, 100%, 50%)", "#ffffff"); + + expect(resultHex).toBeCloseTo(resultRgb, 2); + expect(resultRgb).toBeCloseTo(resultHsl, 2); + }); + }); + + describe("## edge cases", () => { + it("handles identical colors", () => { + const result = textContrast("#ff0000", "#ff0000"); + expect(result).toBeCloseTo(1, 1); + }); + + it("handles identical colors with detailed results", () => { + const result = textContrast("#ff0000", "#ff0000", { + returnDetails: true, + }); + + expect(result.ratio).toBeCloseTo(1, 1); + expect(result.passes.AA_NORMAL).toBe(false); + expect(result.passes.AA_LARGE).toBe(false); + expect(result.passes.AAA_NORMAL).toBe(false); + expect(result.passes.AAA_LARGE).toBe(false); + }); + + it("handles very dark color combinations", () => { + const result = textContrast("#010101", "#000000"); + expect(result).toBeGreaterThan(1); + expect(result).toBeLessThan(1.1); + }); + + it("handles very light color combinations", () => { + const result = textContrast("#fefefe", "#ffffff"); + expect(result).toBeGreaterThan(1); + expect(result).toBeLessThan(1.1); + }); + }); + + describe("## known WCAG reference values", () => { + it("ensures a WCAG AAA Normal and Large reference color pair returns the correct values", () => { + // Expected 8.87:1 AAA Normal and Large test, passes all + const result = textContrast("#96fdc1", "#383F34", { + returnDetails: true, + }); + + expect(result.ratio).toBeCloseTo(8.87, 1); + expect(result.passes.AA_NORMAL).toBe(true); + expect(result.passes.AA_LARGE).toBe(true); + expect(result.passes.AAA_NORMAL).toBe(true); + expect(result.passes.AAA_LARGE).toBe(true); + }); + + it("ensures a WCAG AAA Normal and Large borderline reference color pair returns the correct values", () => { + // Expected 7.03:1 AAA Normal and Large, passes all (borderline) + const result = textContrast("#ffffff", "#4d5a6a", { + returnDetails: true, + }); + + expect(result.ratio).toBeCloseTo(7.03, 1); + expect(result.passes.AA_NORMAL).toBe(true); + expect(result.passes.AA_LARGE).toBe(true); + expect(result.passes.AAA_NORMAL).toBe(true); + expect(result.passes.AAA_LARGE).toBe(true); + }); + + it("ensures a WCAG AAA Large and AA Normal reference color pair returns the correct values", () => { + // Expected 5.72:1 AAA Large, only AA normal + const result = textContrast("#ffffff", "#845c5c", { + returnDetails: true, + }); + + expect(result.ratio).toBeCloseTo(5.72, 1); + expect(result.passes.AA_NORMAL).toBe(true); // 5.72 > 4.5 + expect(result.passes.AA_LARGE).toBe(true); // 5.72 > 3.0 + expect(result.passes.AAA_NORMAL).toBe(false); // 5.72 < 7.0 + expect(result.passes.AAA_LARGE).toBe(true); // 5.72 > 4.5 + }); + + it("ensures a WCAG AA Large only reference color pair returns the correct values", () => { + // Expected 3.75:1 AA Large only, fails AAA Large and all Normal + const result = textContrast("#ffffff", "#9c7c7c", { + returnDetails: true, + }); + + expect(result.ratio).toBeCloseTo(3.75, 1); + expect(result.passes.AA_NORMAL).toBe(false); // 3.75 < 4.5 + expect(result.passes.AA_LARGE).toBe(true); // 3.75 > 3.0 + expect(result.passes.AAA_NORMAL).toBe(false); // 3.75 < 7.0 + expect(result.passes.AAA_LARGE).toBe(false); // 3.75 < 4.5 + }); + + it("ensures a WCAG non-accessible reference color pair returns the correct values", () => { + // Expected 2.46:1 non-accessible fails all + const result = textContrast("#865959", "#a2a9b2", { + returnDetails: true, + }); + + expect(result.ratio).toBeCloseTo(2.46, 1); + expect(result.passes.AA_NORMAL).toBe(false); // 2.46 < 4.5 + expect(result.passes.AA_LARGE).toBe(false); // 2.46 < 3.0 + expect(result.passes.AAA_NORMAL).toBe(false); // 2.46 < 7.0 + expect(result.passes.AAA_LARGE).toBe(false); // 2.46 < 4.5 + }); + }); + + describe("## options parameter", () => { + it("ignores unused legacy options gracefully", () => { + // Test that function works even if legacy options are passed + const result = textContrast("#000000", "#ffffff", { + returnDetails: false, + }); + expect(typeof result).toBe("number"); + }); + + it("defaults returnDetails to false", () => { + const result1 = textContrast("#000000", "#ffffff"); + const result2 = textContrast("#000000", "#ffffff", {}); + const result3 = textContrast("#000000", "#ffffff", { + returnDetails: false, + }); + + expect(result1).toBe(result2); + expect(result2).toBe(result3); + expect(typeof result1).toBe("number"); + }); + }); +}); From ce81145e839fd7f2c5c6fe00eb9529f1020e1ba3 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Tue, 12 Aug 2025 00:51:23 -0500 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=94=A7=20Fix=20exports,=20rename=20?= =?UTF-8?q?entrypoint=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename main.ts to mod.ts to be more deno-like * Update mod exports --- deno.json | 4 ++-- legacy/textContrastForBGColor_test.ts | 2 +- main.ts | 3 --- mod.ts | 8 ++++++++ scripts/build_npm.ts | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 main.ts create mode 100644 mod.ts diff --git a/deno.json b/deno.json index 156eef1..d00e342 100644 --- a/deno.json +++ b/deno.json @@ -22,10 +22,10 @@ "bugs": { "url": "https://github.com/ammuench/contrastrast/issues" }, - "exports": "./main.ts", + "exports": "./mod.ts", "version": "0.3.1", "tasks": { - "dev": "deno run --watch main.ts", + "dev": "deno run --watch mod.ts", "lint-staged": "lint-staged", "prepare": "husky", "build:npm": "deno run -A scripts/build_npm.ts" diff --git a/legacy/textContrastForBGColor_test.ts b/legacy/textContrastForBGColor_test.ts index ed0efaa..a89eb42 100644 --- a/legacy/textContrastForBGColor_test.ts +++ b/legacy/textContrastForBGColor_test.ts @@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; import { faker } from "npm:@faker-js/faker"; -import { textContrastForBGColor } from "../main.ts"; +import { textContrastForBGColor } from "./textContrastForBGColor.ts"; import type { ContrastrastOptions } from "./contrastrastOptions.types.ts"; describe("# textContrastForBGColor", () => { diff --git a/main.ts b/main.ts deleted file mode 100644 index 5eb4f40..0000000 --- a/main.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { Contrastrast } from "./contrastrast.ts"; - -export { textContrastForBGColor } from "./legacy/textContrastForBGColor.ts"; diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..decfb06 --- /dev/null +++ b/mod.ts @@ -0,0 +1,8 @@ +// v1.0.x API +export { Contrastrast } from "./contrastrast.ts"; +export { textContrast } from "./utils/textContrast.ts"; +export { contrastRatio } from "./utils/contrastRatio.ts"; +export type { ContrastOptions, ContrastResult } from "./utils/textContrast.ts"; + +// Legacy v0.3.x API (for backward compatibility) +export { textContrastForBGColor } from "./legacy/textContrastForBGColor.ts"; diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index a260046..ad3b194 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -4,7 +4,7 @@ import { build, emptyDir } from "@deno/dnt"; await emptyDir("./npm"); await build({ - entryPoints: ["./main.ts"], + entryPoints: ["./mod.ts"], outDir: "./npm", importMap: "deno.json", shims: { From 969cc49267952b42e7fef7591cf0178b0f69e962 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Fri, 15 Aug 2025 18:44:05 -0500 Subject: [PATCH 07/22] =?UTF-8?q?=E2=9C=85=20First-pass=20on=20core=20modu?= =?UTF-8?q?le=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- constants/reference-colors.ts | 139 +++++++++++ contrastrast_test.ts | 448 ++++++++++++++++++++++++++++++++++ deno.json | 6 +- 3 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 constants/reference-colors.ts create mode 100644 contrastrast_test.ts diff --git a/constants/reference-colors.ts b/constants/reference-colors.ts new file mode 100644 index 0000000..1b3e65d --- /dev/null +++ b/constants/reference-colors.ts @@ -0,0 +1,139 @@ +export type ReferenceColor = { + hex: { + colorString: string; + }; + rgb: { + r: number; + g: number; + b: number; + colorString: string; + }; + hsl: { + h: string; + s: string; + l: string; + colorString: string; + }; +}; + +export const REFERENCE_COLORS: Record = { + black: { + hex: { + colorString: "#000000", + }, + rgb: { + r: 0, + g: 0, + b: 0, + colorString: "rgb(0, 0, 0)", + }, + hsl: { + h: "0", + s: "0%", + l: "0%", + colorString: "hsl(0, 0%, 0%)", + }, + }, + white: { + hex: { + colorString: "#ffffff", + }, + rgb: { + r: 255, + g: 255, + b: 255, + colorString: "rgb(255, 255, 255)", + }, + hsl: { + h: "0", + s: "0%", + l: "100%", + colorString: "hsl(0, 0%, 100%)", + }, + }, + red: { + hex: { + colorString: "#ff0000", + }, + rgb: { + r: 255, + g: 0, + b: 0, + colorString: "rgb(255, 0, 0)", + }, + hsl: { + h: "0", + s: "100%", + l: "50%", + colorString: "hsl(0, 100%, 50%)", + }, + }, + midnightBlue: { + hex: { + colorString: "#191970", + }, + rgb: { + r: 25, + g: 25, + b: 112, + colorString: "rgb(25, 25, 112)", + }, + hsl: { + h: "240", + s: "64%", + l: "27%", + colorString: "hsl(240, 64%, 27%)", + }, + }, + lightGray: { + hex: { + colorString: "#cccccc", + }, + rgb: { + r: 204, + g: 204, + b: 204, + colorString: "rgb(204, 204, 204)", + }, + hsl: { + h: "0", + s: "0%", + l: "80%", + colorString: "hsl(0, 0%, 80%)", + }, + }, + mediumGray: { + hex: { + colorString: "#767676", + }, + rgb: { + r: 118, + g: 118, + b: 118, + colorString: "rgb(118, 118, 118)", + }, + hsl: { + h: "0", + s: "0%", + l: "46%", + colorString: "hsl(0, 0%, 46%)", + }, + }, + goldenrod: { + hex: { + colorString: "#b89c14", + }, + rgb: { + r: 184, + g: 156, + b: 20, + colorString: "rgb(184, 156, 20)", + }, + hsl: { + h: "50", + s: "80%", + l: "40%", + colorString: "hsl(50, 80%, 40%)", + }, + }, +} as const; diff --git a/contrastrast_test.ts b/contrastrast_test.ts new file mode 100644 index 0000000..054b423 --- /dev/null +++ b/contrastrast_test.ts @@ -0,0 +1,448 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { Contrastrast } from "./contrastrast.ts"; +import { CONTRAST_THRESHOLD, WCAG_LEVELS } from "./constants.ts"; +import type { ContrastResult } from "./utils/textContrast.ts"; +import { referenceColors } from "./constants/reference-colors.ts"; + +describe("# Contrastrast", () => { + describe("## Constructor and Factory Methods", () => { + it("constructor parses HEX color strings", () => { + const color = new Contrastrast(referenceColors.red.hex.colorString); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + + it("constructor parses RGB color strings", () => { + const color = new Contrastrast(referenceColors.red.rgb.colorString); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + + it("constructor parses HSL color strings", () => { + const color = new Contrastrast(referenceColors.red.hsl.colorString); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + + it("fromHex creates instance from hex string", () => { + const color = Contrastrast.fromHex(referenceColors.red.hex.colorString); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + + it("fromHex handles hex without hash", () => { + const color = Contrastrast.fromHex("ff0000"); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + + it("fromRgb creates instance from numbers", () => { + const { r, g, b } = referenceColors.red.rgb; + const color = Contrastrast.fromRgb(r, g, b); + expect(color.toRgb()).toEqual({ r, g, b }); + }); + + it("fromRgb creates instance from object", () => { + const { r, g, b } = referenceColors.red.rgb; + const color = Contrastrast.fromRgb({ r, g, b }); + expect(color.toRgb()).toEqual({ r, g, b }); + }); + + it("fromHsl creates instance from numbers", () => { + const color = Contrastrast.fromHsl(0, 100, 50); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + + it("fromHsl creates instance from object", () => { + const color = Contrastrast.fromHsl({ h: 0, s: 100, l: 50 }); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + + it("parse method works like constructor", () => { + const color = Contrastrast.parse(referenceColors.red.hex.colorString); + expect(color.toRgb()).toEqual({ + r: referenceColors.red.rgb.r, + g: referenceColors.red.rgb.g, + b: referenceColors.red.rgb.b, + }); + }); + }); + + describe("## Conversion Methods", () => { + const redColor = new Contrastrast(referenceColors.red.hex.colorString); + + it("toHex returns hex string with hash by default", () => { + expect(redColor.toHex()).toBe(referenceColors.red.hex.colorString); + }); + + it("toHex returns hex string without hash when requested", () => { + expect(redColor.toHex(false)).toBe("ff0000"); + }); + + it("toRgb returns RGB object", () => { + const { r, g, b } = referenceColors.red.rgb; + expect(redColor.toRgb()).toEqual({ r, g, b }); + }); + + it("toRgbString returns RGB string", () => { + expect(redColor.toRgbString()).toBe(referenceColors.red.rgb.colorString); + }); + + it("toHsl returns HSL object", () => { + expect(redColor.toHsl()).toEqual({ h: 0, s: 100, l: 50 }); + }); + + it("toHslString returns HSL string", () => { + expect(redColor.toHslString()).toBe("hsl(0, 100%, 50%)"); + }); + + it("round-trip conversion preserves color", () => { + const original = referenceColors.goldenrod.hex.colorString; + const color = new Contrastrast(original); + const roundTrip = color.toHex(); + expect(roundTrip).toBe(original); + }); + }); + + describe("## Luminance and Brightness Calculations", () => { + it("luminance calculates WCAG 2.1 relative luminance", () => { + const black = new Contrastrast(referenceColors.black.hex.colorString); + const white = new Contrastrast(referenceColors.white.hex.colorString); + + expect(black.luminance()).toBe(0); + expect(white.luminance()).toBe(1); + }); + + it("brightness calculates legacy AERT brightness", () => { + const black = new Contrastrast(referenceColors.black.hex.colorString); + const white = new Contrastrast(referenceColors.white.hex.colorString); + + expect(black.brightness()).toBe(0); + expect(white.brightness()).toBe(255); + }); + + it("midnight blue has expected luminance", () => { + const midnightBlue = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + // Expected luminance for midnight blue (#191970) + expect(midnightBlue.luminance()).toBeCloseTo(0.0207, 3); + }); + }); + + describe("## Utility Methods", () => { + it("isLight returns true for light colors", () => { + const white = new Contrastrast(referenceColors.white.hex.colorString); + const lightGray = new Contrastrast( + referenceColors.lightGray.hex.colorString, + ); + + expect(white.isLight()).toBe(true); + expect(lightGray.isLight()).toBe(true); + }); + + it("isLight returns false for dark colors", () => { + const black = new Contrastrast(referenceColors.black.hex.colorString); + const darkBlue = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + + expect(black.isLight()).toBe(false); + expect(darkBlue.isLight()).toBe(false); + }); + + it("isDark is opposite of isLight", () => { + const white = new Contrastrast(referenceColors.white.hex.colorString); + const black = new Contrastrast(referenceColors.black.hex.colorString); + + expect(white.isDark()).toBe(!white.isLight()); + expect(black.isDark()).toBe(!black.isLight()); + }); + + it("isLight uses brightness threshold", () => { + // Create colors around the threshold + // Use a balanced RGB that gets close to 124 brightness + // (100 * 299 + 100 * 587 + 100 * 114) / 1000 = 100 + // Need to increase to get closer to 124 + const darkColor = Contrastrast.fromRgb(110, 110, 110); // ~110 brightness + const lightColor = Contrastrast.fromRgb(130, 130, 130); // ~130 brightness + + expect(darkColor.brightness()).toBeLessThan(CONTRAST_THRESHOLD); + expect(darkColor.isLight()).toBe(false); + expect(lightColor.brightness()).toBeGreaterThan(CONTRAST_THRESHOLD); + expect(lightColor.isLight()).toBe(true); + }); + }); + + describe("## Contrast Ratio Calculations", () => { + it("contrastRatio method works with string input", () => { + const black = new Contrastrast(referenceColors.black.hex.colorString); + const ratio = black.contrastRatio(referenceColors.white.hex.colorString); + expect(ratio).toBe(21); + }); + + it("contrastRatio method works with Contrastrast input", () => { + const black = new Contrastrast(referenceColors.black.hex.colorString); + const white = new Contrastrast(referenceColors.white.hex.colorString); + const ratio = black.contrastRatio(white); + expect(ratio).toBe(21); + }); + + it("contrastRatio is symmetric", () => { + const color1 = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + const color2 = new Contrastrast(referenceColors.white.hex.colorString); + + expect(color1.contrastRatio(color2)).toBe(color2.contrastRatio(color1)); + }); + }); + + describe("## textContrast Instance Method", () => { + const midnightBlue = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + + it("returns numeric ratio by default", () => { + const ratio = midnightBlue.textContrast( + referenceColors.white.hex.colorString, + ); + expect(typeof ratio).toBe("number"); + expect(ratio).toBeCloseTo(14.85, 1); + }); + + it("role parameter defaults to 'background'", () => { + const ratioDefault = midnightBlue.textContrast( + referenceColors.white.hex.colorString, + ); + const ratioExplicit = midnightBlue.textContrast( + referenceColors.white.hex.colorString, + "background", + ); + expect(ratioDefault).toBe(ratioExplicit); + }); + + it("handles 'foreground' role correctly", () => { + // When this color is foreground, white is background + const ratio = midnightBlue.textContrast( + referenceColors.white.hex.colorString, + "foreground", + ); + expect(ratio).toBeCloseTo(14.85, 1); + }); + + it("accepts Contrastrast instance as input", () => { + const white = new Contrastrast(referenceColors.white.hex.colorString); + const ratio = midnightBlue.textContrast(white); + expect(ratio).toBeCloseTo(14.85, 1); + }); + + it("returns detailed results with returnDetails: true", () => { + const result = midnightBlue.textContrast( + referenceColors.white.hex.colorString, + "background", + { returnDetails: true }, + ) as ContrastResult; + + expect(result.ratio).toBeCloseTo(14.85, 1); + expect(result.passes.AA_NORMAL).toBe(true); + expect(result.passes.AA_LARGE).toBe(true); + expect(result.passes.AAA_NORMAL).toBe(true); + expect(result.passes.AAA_LARGE).toBe(true); + }); + + it("detailed results show failing combinations", () => { + const lightGray = new Contrastrast( + referenceColors.lightGray.hex.colorString, + ); + const result = lightGray.textContrast( + referenceColors.white.hex.colorString, + "background", + { returnDetails: true }, + ) as ContrastResult; + + expect(result.ratio).toBeCloseTo(1.61, 1); + expect(result.passes.AA_NORMAL).toBe(false); + expect(result.passes.AA_LARGE).toBe(false); + expect(result.passes.AAA_NORMAL).toBe(false); + expect(result.passes.AAA_LARGE).toBe(false); + }); + }); + + describe("## WCAG Compliance Helper", () => { + const midnightBlue = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + const whiteColor = referenceColors.white.hex.colorString; + + it("meetsWCAG returns true for compliant combinations", () => { + expect(midnightBlue.meetsWCAG(whiteColor, "background", "AA", "normal")) + .toBe(true); + expect(midnightBlue.meetsWCAG(whiteColor, "background", "AA", "large")) + .toBe(true); + expect(midnightBlue.meetsWCAG(whiteColor, "background", "AAA", "normal")) + .toBe(true); + expect(midnightBlue.meetsWCAG(whiteColor, "background", "AAA", "large")) + .toBe(true); + }); + + it("meetsWCAG returns false for non-compliant combinations", () => { + const lightGray = new Contrastrast( + referenceColors.lightGray.hex.colorString, + ); + + expect(lightGray.meetsWCAG(whiteColor, "background", "AA", "normal")) + .toBe(false); + expect(lightGray.meetsWCAG(whiteColor, "background", "AA", "large")).toBe( + false, + ); + }); + + it("meetsWCAG defaults to normal text size", () => { + const resultWithDefault = midnightBlue.meetsWCAG( + whiteColor, + "background", + "AA", + ); + const resultExplicit = midnightBlue.meetsWCAG( + whiteColor, + "background", + "AA", + "normal", + ); + expect(resultWithDefault).toBe(resultExplicit); + }); + + it("meetsWCAG uses correct thresholds", () => { + // Use medium gray which should be close to AA normal threshold (4.5) + const mediumGray = new Contrastrast( + referenceColors.mediumGray.hex.colorString, + ); + + const ratio = mediumGray.contrastRatio(whiteColor); + const meetsAA = ratio >= WCAG_LEVELS.AA.normal; + + expect(mediumGray.meetsWCAG(whiteColor, "background", "AA", "normal")) + .toBe(meetsAA); + }); + }); + + describe("## Color Equality", () => { + it("equals returns true for identical colors", () => { + const color1 = new Contrastrast(referenceColors.red.hex.colorString); + const color2 = new Contrastrast(referenceColors.red.hex.colorString); + expect(color1.equals(color2)).toBe(true); + }); + + it("equals returns true for equivalent colors in different formats", () => { + const hexColor = new Contrastrast(referenceColors.red.hex.colorString); + const rgbColor = new Contrastrast(referenceColors.red.rgb.colorString); + expect(hexColor.equals(rgbColor)).toBe(true); + }); + + it("equals works with string input", () => { + const color = new Contrastrast(referenceColors.red.hex.colorString); + expect(color.equals(referenceColors.red.hex.colorString)).toBe(true); + expect(color.equals(referenceColors.red.rgb.colorString)).toBe(true); + }); + + it("equals returns false for different colors", () => { + const red = new Contrastrast(referenceColors.red.hex.colorString); + const midnightBlue = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + expect(red.equals(midnightBlue)).toBe(false); + }); + }); + + describe("## Integration Tests", () => { + it("complex workflow with multiple operations", () => { + // Create a brand color and analyze its accessibility + const brandColor = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + + // Check if it works well with white text + const whiteTextRatio = brandColor.textContrast( + referenceColors.white.hex.colorString, + "background", + ); + expect(whiteTextRatio).toBeGreaterThanOrEqual(4.5); + + // Verify WCAG compliance + expect( + brandColor.meetsWCAG( + referenceColors.white.hex.colorString, + "background", + "AA", + ), + ).toBe(true); + + // Check luminance properties + expect(brandColor.isDark()).toBe(true); + expect(brandColor.luminance()).toBeLessThan(0.5); + }); + + it("ensures consistency across different color formats", () => { + const goldenrod = referenceColors.goldenrod; + + const color1 = new Contrastrast(goldenrod.hex.colorString); + const color2 = new Contrastrast(goldenrod.rgb.colorString); + const color3 = new Contrastrast(goldenrod.hsl.colorString); + + // All should have very similar RGB values (within rounding) + const rgb1 = color1.toRgb(); + const rgb2 = color2.toRgb(); + const rgb3 = color3.toRgb(); + + expect(Math.abs(rgb1.r - rgb2.r)).toBeLessThanOrEqual(1); + expect(Math.abs(rgb1.g - rgb2.g)).toBeLessThanOrEqual(1); + expect(Math.abs(rgb1.b - rgb2.b)).toBeLessThanOrEqual(1); + + expect(Math.abs(rgb1.r - rgb3.r)).toBeLessThanOrEqual(2); + expect(Math.abs(rgb1.g - rgb3.g)).toBeLessThanOrEqual(2); + expect(Math.abs(rgb1.b - rgb3.b)).toBeLessThanOrEqual(2); + }); + + it("validates immutability of instances", () => { + const original = new Contrastrast( + referenceColors.midnightBlue.hex.colorString, + ); + const originalRgb = original.toRgb(); + + // Calling methods should not modify the original + original.toHex(); + original.toHsl(); + original.textContrast(referenceColors.white.hex.colorString); + original.contrastRatio(referenceColors.black.hex.colorString); + original.equals(referenceColors.midnightBlue.hex.colorString); + + // RGB values should remain unchanged + expect(original.toRgb()).toEqual(originalRgb); + }); + }); +}); diff --git a/deno.json b/deno.json index d00e342..37d3665 100644 --- a/deno.json +++ b/deno.json @@ -34,14 +34,16 @@ "npm/", "__SPECS__/", "scripts/", - "node_modules/" + "node_modules/", + "demo.ts" ], "test": { "exclude": [ "npm/", "__SPECS__/", "scripts/", - "node_modules/" + "node_modules/", + "demo.ts" ] }, "imports": { From 2ede29f7eb9d4bcae8ebbe3e50ed70890c8a0441 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Fri, 15 Aug 2025 19:13:30 -0500 Subject: [PATCH 08/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Clean=20up=20contras?= =?UTF-8?q?t=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrastrast_test.ts | 168 ++++++++++++++++++------------------- deno.json | 3 +- utils/textContrast_test.ts | 54 ++---------- 3 files changed, 92 insertions(+), 133 deletions(-) diff --git a/contrastrast_test.ts b/contrastrast_test.ts index 054b423..43cc3f4 100644 --- a/contrastrast_test.ts +++ b/contrastrast_test.ts @@ -3,63 +3,63 @@ import { describe, it } from "@std/testing/bdd"; import { Contrastrast } from "./contrastrast.ts"; import { CONTRAST_THRESHOLD, WCAG_LEVELS } from "./constants.ts"; import type { ContrastResult } from "./utils/textContrast.ts"; -import { referenceColors } from "./constants/reference-colors.ts"; +import { REFERENCE_COLORS } from "./constants/reference-colors.ts"; describe("# Contrastrast", () => { describe("## Constructor and Factory Methods", () => { it("constructor parses HEX color strings", () => { - const color = new Contrastrast(referenceColors.red.hex.colorString); + const color = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); it("constructor parses RGB color strings", () => { - const color = new Contrastrast(referenceColors.red.rgb.colorString); + const color = new Contrastrast(REFERENCE_COLORS.red.rgb.colorString); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); it("constructor parses HSL color strings", () => { - const color = new Contrastrast(referenceColors.red.hsl.colorString); + const color = new Contrastrast(REFERENCE_COLORS.red.hsl.colorString); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); it("fromHex creates instance from hex string", () => { - const color = Contrastrast.fromHex(referenceColors.red.hex.colorString); + const color = Contrastrast.fromHex(REFERENCE_COLORS.red.hex.colorString); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); it("fromHex handles hex without hash", () => { const color = Contrastrast.fromHex("ff0000"); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); it("fromRgb creates instance from numbers", () => { - const { r, g, b } = referenceColors.red.rgb; + const { r, g, b } = REFERENCE_COLORS.red.rgb; const color = Contrastrast.fromRgb(r, g, b); expect(color.toRgb()).toEqual({ r, g, b }); }); it("fromRgb creates instance from object", () => { - const { r, g, b } = referenceColors.red.rgb; + const { r, g, b } = REFERENCE_COLORS.red.rgb; const color = Contrastrast.fromRgb({ r, g, b }); expect(color.toRgb()).toEqual({ r, g, b }); }); @@ -67,36 +67,36 @@ describe("# Contrastrast", () => { it("fromHsl creates instance from numbers", () => { const color = Contrastrast.fromHsl(0, 100, 50); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); it("fromHsl creates instance from object", () => { const color = Contrastrast.fromHsl({ h: 0, s: 100, l: 50 }); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); it("parse method works like constructor", () => { - const color = Contrastrast.parse(referenceColors.red.hex.colorString); + const color = Contrastrast.parse(REFERENCE_COLORS.red.hex.colorString); expect(color.toRgb()).toEqual({ - r: referenceColors.red.rgb.r, - g: referenceColors.red.rgb.g, - b: referenceColors.red.rgb.b, + r: REFERENCE_COLORS.red.rgb.r, + g: REFERENCE_COLORS.red.rgb.g, + b: REFERENCE_COLORS.red.rgb.b, }); }); }); describe("## Conversion Methods", () => { - const redColor = new Contrastrast(referenceColors.red.hex.colorString); + const redColor = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); it("toHex returns hex string with hash by default", () => { - expect(redColor.toHex()).toBe(referenceColors.red.hex.colorString); + expect(redColor.toHex()).toBe(REFERENCE_COLORS.red.hex.colorString); }); it("toHex returns hex string without hash when requested", () => { @@ -104,12 +104,12 @@ describe("# Contrastrast", () => { }); it("toRgb returns RGB object", () => { - const { r, g, b } = referenceColors.red.rgb; + const { r, g, b } = REFERENCE_COLORS.red.rgb; expect(redColor.toRgb()).toEqual({ r, g, b }); }); it("toRgbString returns RGB string", () => { - expect(redColor.toRgbString()).toBe(referenceColors.red.rgb.colorString); + expect(redColor.toRgbString()).toBe(REFERENCE_COLORS.red.rgb.colorString); }); it("toHsl returns HSL object", () => { @@ -121,7 +121,7 @@ describe("# Contrastrast", () => { }); it("round-trip conversion preserves color", () => { - const original = referenceColors.goldenrod.hex.colorString; + const original = REFERENCE_COLORS.goldenrod.hex.colorString; const color = new Contrastrast(original); const roundTrip = color.toHex(); expect(roundTrip).toBe(original); @@ -130,16 +130,16 @@ describe("# Contrastrast", () => { describe("## Luminance and Brightness Calculations", () => { it("luminance calculates WCAG 2.1 relative luminance", () => { - const black = new Contrastrast(referenceColors.black.hex.colorString); - const white = new Contrastrast(referenceColors.white.hex.colorString); + const black = new Contrastrast(REFERENCE_COLORS.black.hex.colorString); + const white = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); expect(black.luminance()).toBe(0); expect(white.luminance()).toBe(1); }); it("brightness calculates legacy AERT brightness", () => { - const black = new Contrastrast(referenceColors.black.hex.colorString); - const white = new Contrastrast(referenceColors.white.hex.colorString); + const black = new Contrastrast(REFERENCE_COLORS.black.hex.colorString); + const white = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); expect(black.brightness()).toBe(0); expect(white.brightness()).toBe(255); @@ -147,7 +147,7 @@ describe("# Contrastrast", () => { it("midnight blue has expected luminance", () => { const midnightBlue = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); // Expected luminance for midnight blue (#191970) expect(midnightBlue.luminance()).toBeCloseTo(0.0207, 3); @@ -156,9 +156,9 @@ describe("# Contrastrast", () => { describe("## Utility Methods", () => { it("isLight returns true for light colors", () => { - const white = new Contrastrast(referenceColors.white.hex.colorString); + const white = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); const lightGray = new Contrastrast( - referenceColors.lightGray.hex.colorString, + REFERENCE_COLORS.lightGray.hex.colorString, ); expect(white.isLight()).toBe(true); @@ -166,9 +166,9 @@ describe("# Contrastrast", () => { }); it("isLight returns false for dark colors", () => { - const black = new Contrastrast(referenceColors.black.hex.colorString); + const black = new Contrastrast(REFERENCE_COLORS.black.hex.colorString); const darkBlue = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); expect(black.isLight()).toBe(false); @@ -176,8 +176,8 @@ describe("# Contrastrast", () => { }); it("isDark is opposite of isLight", () => { - const white = new Contrastrast(referenceColors.white.hex.colorString); - const black = new Contrastrast(referenceColors.black.hex.colorString); + const white = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); + const black = new Contrastrast(REFERENCE_COLORS.black.hex.colorString); expect(white.isDark()).toBe(!white.isLight()); expect(black.isDark()).toBe(!black.isLight()); @@ -200,23 +200,23 @@ describe("# Contrastrast", () => { describe("## Contrast Ratio Calculations", () => { it("contrastRatio method works with string input", () => { - const black = new Contrastrast(referenceColors.black.hex.colorString); - const ratio = black.contrastRatio(referenceColors.white.hex.colorString); + const black = new Contrastrast(REFERENCE_COLORS.black.hex.colorString); + const ratio = black.contrastRatio(REFERENCE_COLORS.white.hex.colorString); expect(ratio).toBe(21); }); it("contrastRatio method works with Contrastrast input", () => { - const black = new Contrastrast(referenceColors.black.hex.colorString); - const white = new Contrastrast(referenceColors.white.hex.colorString); + const black = new Contrastrast(REFERENCE_COLORS.black.hex.colorString); + const white = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); const ratio = black.contrastRatio(white); expect(ratio).toBe(21); }); it("contrastRatio is symmetric", () => { const color1 = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); - const color2 = new Contrastrast(referenceColors.white.hex.colorString); + const color2 = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); expect(color1.contrastRatio(color2)).toBe(color2.contrastRatio(color1)); }); @@ -224,12 +224,12 @@ describe("# Contrastrast", () => { describe("## textContrast Instance Method", () => { const midnightBlue = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); it("returns numeric ratio by default", () => { const ratio = midnightBlue.textContrast( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, ); expect(typeof ratio).toBe("number"); expect(ratio).toBeCloseTo(14.85, 1); @@ -237,10 +237,10 @@ describe("# Contrastrast", () => { it("role parameter defaults to 'background'", () => { const ratioDefault = midnightBlue.textContrast( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, ); const ratioExplicit = midnightBlue.textContrast( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, "background", ); expect(ratioDefault).toBe(ratioExplicit); @@ -249,21 +249,21 @@ describe("# Contrastrast", () => { it("handles 'foreground' role correctly", () => { // When this color is foreground, white is background const ratio = midnightBlue.textContrast( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, "foreground", ); expect(ratio).toBeCloseTo(14.85, 1); }); it("accepts Contrastrast instance as input", () => { - const white = new Contrastrast(referenceColors.white.hex.colorString); + const white = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); const ratio = midnightBlue.textContrast(white); expect(ratio).toBeCloseTo(14.85, 1); }); it("returns detailed results with returnDetails: true", () => { const result = midnightBlue.textContrast( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, "background", { returnDetails: true }, ) as ContrastResult; @@ -277,10 +277,10 @@ describe("# Contrastrast", () => { it("detailed results show failing combinations", () => { const lightGray = new Contrastrast( - referenceColors.lightGray.hex.colorString, + REFERENCE_COLORS.lightGray.hex.colorString, ); const result = lightGray.textContrast( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, "background", { returnDetails: true }, ) as ContrastResult; @@ -295,9 +295,9 @@ describe("# Contrastrast", () => { describe("## WCAG Compliance Helper", () => { const midnightBlue = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); - const whiteColor = referenceColors.white.hex.colorString; + const whiteColor = REFERENCE_COLORS.white.hex.colorString; it("meetsWCAG returns true for compliant combinations", () => { expect(midnightBlue.meetsWCAG(whiteColor, "background", "AA", "normal")) @@ -312,7 +312,7 @@ describe("# Contrastrast", () => { it("meetsWCAG returns false for non-compliant combinations", () => { const lightGray = new Contrastrast( - referenceColors.lightGray.hex.colorString, + REFERENCE_COLORS.lightGray.hex.colorString, ); expect(lightGray.meetsWCAG(whiteColor, "background", "AA", "normal")) @@ -340,7 +340,7 @@ describe("# Contrastrast", () => { it("meetsWCAG uses correct thresholds", () => { // Use medium gray which should be close to AA normal threshold (4.5) const mediumGray = new Contrastrast( - referenceColors.mediumGray.hex.colorString, + REFERENCE_COLORS.mediumGray.hex.colorString, ); const ratio = mediumGray.contrastRatio(whiteColor); @@ -353,27 +353,27 @@ describe("# Contrastrast", () => { describe("## Color Equality", () => { it("equals returns true for identical colors", () => { - const color1 = new Contrastrast(referenceColors.red.hex.colorString); - const color2 = new Contrastrast(referenceColors.red.hex.colorString); + const color1 = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); + const color2 = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); expect(color1.equals(color2)).toBe(true); }); it("equals returns true for equivalent colors in different formats", () => { - const hexColor = new Contrastrast(referenceColors.red.hex.colorString); - const rgbColor = new Contrastrast(referenceColors.red.rgb.colorString); + const hexColor = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); + const rgbColor = new Contrastrast(REFERENCE_COLORS.red.rgb.colorString); expect(hexColor.equals(rgbColor)).toBe(true); }); it("equals works with string input", () => { - const color = new Contrastrast(referenceColors.red.hex.colorString); - expect(color.equals(referenceColors.red.hex.colorString)).toBe(true); - expect(color.equals(referenceColors.red.rgb.colorString)).toBe(true); + const color = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); + expect(color.equals(REFERENCE_COLORS.red.hex.colorString)).toBe(true); + expect(color.equals(REFERENCE_COLORS.red.rgb.colorString)).toBe(true); }); it("equals returns false for different colors", () => { - const red = new Contrastrast(referenceColors.red.hex.colorString); + const red = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); const midnightBlue = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); expect(red.equals(midnightBlue)).toBe(false); }); @@ -383,12 +383,12 @@ describe("# Contrastrast", () => { it("complex workflow with multiple operations", () => { // Create a brand color and analyze its accessibility const brandColor = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); // Check if it works well with white text const whiteTextRatio = brandColor.textContrast( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, "background", ); expect(whiteTextRatio).toBeGreaterThanOrEqual(4.5); @@ -396,7 +396,7 @@ describe("# Contrastrast", () => { // Verify WCAG compliance expect( brandColor.meetsWCAG( - referenceColors.white.hex.colorString, + REFERENCE_COLORS.white.hex.colorString, "background", "AA", ), @@ -408,7 +408,7 @@ describe("# Contrastrast", () => { }); it("ensures consistency across different color formats", () => { - const goldenrod = referenceColors.goldenrod; + const goldenrod = REFERENCE_COLORS.goldenrod; const color1 = new Contrastrast(goldenrod.hex.colorString); const color2 = new Contrastrast(goldenrod.rgb.colorString); @@ -430,16 +430,16 @@ describe("# Contrastrast", () => { it("validates immutability of instances", () => { const original = new Contrastrast( - referenceColors.midnightBlue.hex.colorString, + REFERENCE_COLORS.midnightBlue.hex.colorString, ); const originalRgb = original.toRgb(); // Calling methods should not modify the original original.toHex(); original.toHsl(); - original.textContrast(referenceColors.white.hex.colorString); - original.contrastRatio(referenceColors.black.hex.colorString); - original.equals(referenceColors.midnightBlue.hex.colorString); + original.textContrast(REFERENCE_COLORS.white.hex.colorString); + original.contrastRatio(REFERENCE_COLORS.black.hex.colorString); + original.equals(REFERENCE_COLORS.midnightBlue.hex.colorString); // RGB values should remain unchanged expect(original.toRgb()).toEqual(originalRgb); diff --git a/deno.json b/deno.json index 37d3665..9ae3d7f 100644 --- a/deno.json +++ b/deno.json @@ -34,8 +34,7 @@ "npm/", "__SPECS__/", "scripts/", - "node_modules/", - "demo.ts" + "node_modules/" ], "test": { "exclude": [ diff --git a/utils/textContrast_test.ts b/utils/textContrast_test.ts index 7433bac..5763891 100644 --- a/utils/textContrast_test.ts +++ b/utils/textContrast_test.ts @@ -1,5 +1,6 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; +import { faker } from "npm:@faker-js/faker"; import { textContrast } from "./textContrast.ts"; import { contrastRatio } from "./contrastRatio.ts"; import { Contrastrast } from "../contrastrast.ts"; @@ -26,64 +27,23 @@ describe("# textContrast", () => { }); describe("## detailed results", () => { - it("returns detailed results when returnDetails is true", () => { - const result = textContrast("#000000", "#ffffff", { + it("returns detailed results with correct structure when returnDetails is true", () => { + const color1 = faker.color.rgb({ format: "hex" }); + const color2 = faker.color.rgb({ format: "hex" }); + + const result = textContrast(color1, color2, { returnDetails: true, }); expect(typeof result).toBe("object"); expect(result).toHaveProperty("ratio"); expect(result).toHaveProperty("passes"); - expect(result.ratio).toBeCloseTo(21, 1); - }); - - it("includes all WCAG compliance checks in passes object", () => { - const result = textContrast("#1a73e8", "#ffffff", { - returnDetails: true, - }); - + expect(typeof result.ratio).toBe("number"); expect(result.passes).toHaveProperty("AA_NORMAL"); expect(result.passes).toHaveProperty("AA_LARGE"); expect(result.passes).toHaveProperty("AAA_NORMAL"); expect(result.passes).toHaveProperty("AAA_LARGE"); }); - - it("correctly identifies passing WCAG combinations", () => { - // Black on white should pass all WCAG levels - const result = textContrast("#000000", "#ffffff", { - returnDetails: true, - }); - - expect(result.passes.AA_NORMAL).toBe(true); - expect(result.passes.AA_LARGE).toBe(true); - expect(result.passes.AAA_NORMAL).toBe(true); - expect(result.passes.AAA_LARGE).toBe(true); - }); - - it("correctly identifies failing WCAG combinations", () => { - // Light gray on white should fail all WCAG levels - const result = textContrast("#cccccc", "#ffffff", { - returnDetails: true, - }); - - expect(result.passes.AA_NORMAL).toBe(false); - expect(result.passes.AA_LARGE).toBe(false); - expect(result.passes.AAA_NORMAL).toBe(false); - expect(result.passes.AAA_LARGE).toBe(false); - }); - - it("correctly identifies partial WCAG compliance", () => { - // Medium gray should pass AA but not AAA normal - const result = textContrast("#666666", "#ffffff", { - returnDetails: true, - }); - - // Should pass AA (both normal and large) and AAA large, but not AAA normal - expect(result.passes.AA_NORMAL).toBe(true); // 5.74 > 4.5 - expect(result.passes.AA_LARGE).toBe(true); // 5.74 > 3.0 - expect(result.passes.AAA_NORMAL).toBe(false); // 5.74 < 7.0 - expect(result.passes.AAA_LARGE).toBe(true); // 5.74 > 4.5 - }); }); describe("## input format flexibility", () => { From 6355c97bd4aa2a1c7f6811b22c2a2862849a35f8 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Fri, 15 Aug 2025 19:35:20 -0500 Subject: [PATCH 09/22] =?UTF-8?q?=F0=9F=90=9B=20Fix=20HSL=20parsing,=20upd?= =?UTF-8?q?ate=20tests=20to=20stress=20parsing=20better?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrastrast_test.ts | 212 +++++++++++++++++++++++++++------------ helpers/rgbConverters.ts | 14 +-- 2 files changed, 156 insertions(+), 70 deletions(-) diff --git a/contrastrast_test.ts b/contrastrast_test.ts index 43cc3f4..56149e4 100644 --- a/contrastrast_test.ts +++ b/contrastrast_test.ts @@ -6,88 +6,174 @@ import type { ContrastResult } from "./utils/textContrast.ts"; import { REFERENCE_COLORS } from "./constants/reference-colors.ts"; describe("# Contrastrast", () => { - describe("## Constructor and Factory Methods", () => { - it("constructor parses HEX color strings", () => { - const color = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + describe("## Color parsing", () => { + describe("### HEX parsing", () => { + it("constructor parsing preserves HEX values", () => { + const originalHex = REFERENCE_COLORS.lightGray.hex.colorString; + const color = new Contrastrast(originalHex); + expect(color.toHex()).toBe(originalHex); }); - }); - it("constructor parses RGB color strings", () => { - const color = new Contrastrast(REFERENCE_COLORS.red.rgb.colorString); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + it("fromHex factory method preserves HEX values", () => { + const originalHex = REFERENCE_COLORS.midnightBlue.hex.colorString; + const color = Contrastrast.fromHex(originalHex); + expect(color.toHex()).toBe(originalHex); }); - }); - it("constructor parses HSL color strings", () => { - const color = new Contrastrast(REFERENCE_COLORS.red.hsl.colorString); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + it("fromHex handles hex without hash", () => { + const color = Contrastrast.fromHex("ff0000"); + expect(color.toHex()).toBe("#ff0000"); }); - }); - it("fromHex creates instance from hex string", () => { - const color = Contrastrast.fromHex(REFERENCE_COLORS.red.hex.colorString); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + it("multiple HEX colors round-trip correctly", () => { + const testColors = [ + REFERENCE_COLORS.black, + REFERENCE_COLORS.white, + REFERENCE_COLORS.red, + REFERENCE_COLORS.lightGray, + REFERENCE_COLORS.mediumGray, + REFERENCE_COLORS.goldenrod, + REFERENCE_COLORS.midnightBlue, + ]; + + testColors.forEach((refColor) => { + const hexColor = new Contrastrast(refColor.hex.colorString); + expect(hexColor.toHex()).toBe(refColor.hex.colorString); + }); }); }); - it("fromHex handles hex without hash", () => { - const color = Contrastrast.fromHex("ff0000"); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + describe("### RGB parsing", () => { + it("constructor parsing preserves RGB values", () => { + const originalRgb = REFERENCE_COLORS.lightGray.rgb; + const color = new Contrastrast(originalRgb.colorString); + expect(color.toRgb()).toEqual({ + r: originalRgb.r, + g: originalRgb.g, + b: originalRgb.b, + }); }); - }); - it("fromRgb creates instance from numbers", () => { - const { r, g, b } = REFERENCE_COLORS.red.rgb; - const color = Contrastrast.fromRgb(r, g, b); - expect(color.toRgb()).toEqual({ r, g, b }); - }); + it("fromRgb factory method preserves RGB values", () => { + const originalRgb = REFERENCE_COLORS.goldenrod.rgb; + const color = Contrastrast.fromRgb( + originalRgb.r, + originalRgb.g, + originalRgb.b, + ); + expect(color.toRgb()).toEqual({ + r: originalRgb.r, + g: originalRgb.g, + b: originalRgb.b, + }); + }); - it("fromRgb creates instance from object", () => { - const { r, g, b } = REFERENCE_COLORS.red.rgb; - const color = Contrastrast.fromRgb({ r, g, b }); - expect(color.toRgb()).toEqual({ r, g, b }); - }); + it("fromRgb with object preserves RGB values", () => { + const originalRgb = REFERENCE_COLORS.goldenrod.rgb; + const color = Contrastrast.fromRgb({ + r: originalRgb.r, + g: originalRgb.g, + b: originalRgb.b, + }); + expect(color.toRgb()).toEqual({ + r: originalRgb.r, + g: originalRgb.g, + b: originalRgb.b, + }); + }); - it("fromHsl creates instance from numbers", () => { - const color = Contrastrast.fromHsl(0, 100, 50); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + it("multiple RGB colors round-trip correctly", () => { + const testColors = [ + REFERENCE_COLORS.black, + REFERENCE_COLORS.white, + REFERENCE_COLORS.red, + REFERENCE_COLORS.lightGray, + REFERENCE_COLORS.mediumGray, + REFERENCE_COLORS.goldenrod, + REFERENCE_COLORS.midnightBlue, + ]; + + testColors.forEach((refColor) => { + const rgbColor = new Contrastrast(refColor.rgb.colorString); + expect(rgbColor.toRgb()).toEqual({ + r: refColor.rgb.r, + g: refColor.rgb.g, + b: refColor.rgb.b, + }); + }); }); }); - it("fromHsl creates instance from object", () => { - const color = Contrastrast.fromHsl({ h: 0, s: 100, l: 50 }); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + describe("### HSL parsing", () => { + it("constructor parsing preserves HSL values", () => { + const originalHsl = REFERENCE_COLORS.lightGray.hsl; + const color = new Contrastrast(originalHsl.colorString); + const parsedHsl = color.toHsl(); + expect(parsedHsl).toEqual({ + h: parseInt(originalHsl.h), + s: parseInt(originalHsl.s), + l: parseInt(originalHsl.l), + }); + }); + + it("fromHsl factory method preserves HSL values", () => { + const originalHsl = REFERENCE_COLORS.mediumGray.hsl; + const color = Contrastrast.fromHsl( + parseInt(originalHsl.h), + parseInt(originalHsl.s), + parseInt(originalHsl.l), + ); + const parsedHsl = color.toHsl(); + expect(parsedHsl).toEqual({ + h: parseInt(originalHsl.h), + s: parseInt(originalHsl.s), + l: parseInt(originalHsl.l), + }); + }); + + it("fromHsl with object preserves HSL values", () => { + const originalHsl = REFERENCE_COLORS.mediumGray.hsl; + const color = Contrastrast.fromHsl({ + h: parseInt(originalHsl.h), + s: parseInt(originalHsl.s), + l: parseInt(originalHsl.l), + }); + const parsedHsl = color.toHsl(); + expect(parsedHsl).toEqual({ + h: parseInt(originalHsl.h), + s: parseInt(originalHsl.s), + l: parseInt(originalHsl.l), + }); + }); + + it("multiple HSL colors round-trip correctly", () => { + const testColors = [ + REFERENCE_COLORS.black, + REFERENCE_COLORS.white, + REFERENCE_COLORS.red, + REFERENCE_COLORS.lightGray, + REFERENCE_COLORS.mediumGray, + REFERENCE_COLORS.goldenrod, + REFERENCE_COLORS.midnightBlue, + ]; + + testColors.forEach((refColor) => { + const hslColor = new Contrastrast(refColor.hsl.colorString); + const parsedHsl = hslColor.toHsl(); + expect(parsedHsl).toEqual({ + h: parseInt(refColor.hsl.h), + s: parseInt(refColor.hsl.s), + l: parseInt(refColor.hsl.l), + }); + }); }); }); - it("parse method works like constructor", () => { - const color = Contrastrast.parse(REFERENCE_COLORS.red.hex.colorString); - expect(color.toRgb()).toEqual({ - r: REFERENCE_COLORS.red.rgb.r, - g: REFERENCE_COLORS.red.rgb.g, - b: REFERENCE_COLORS.red.rgb.b, + describe("### General parsing", () => { + it("parse method works like constructor", () => { + const originalHex = REFERENCE_COLORS.red.hex.colorString; + const color = Contrastrast.parse(originalHex); + expect(color.toHex()).toBe(originalHex); }); }); }); diff --git a/helpers/rgbConverters.ts b/helpers/rgbConverters.ts index 07017ff..c8140d2 100644 --- a/helpers/rgbConverters.ts +++ b/helpers/rgbConverters.ts @@ -49,7 +49,7 @@ export const extractRGBValuesFromHSL = ( let r, g, b; if (s == 0) { - r = g = b = l; // achromatic + r = g = b = l * 255; // achromatic } else { const hue2rgb = (p: number, q: number, t: number): number => { if (t < 0) t += 1; @@ -63,15 +63,15 @@ export const extractRGBValuesFromHSL = ( const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; - r = hue2rgb(p, q, h + 1 / 3) * 255; - g = hue2rgb(p, q, h) * 255; - b = hue2rgb(p, q, h - 1 / 3) * 255; + r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255); + g = Math.round(hue2rgb(p, q, h) * 255); + b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255); } return { - r, - g, - b, + r: Math.round(r), + g: Math.round(g), + b: Math.round(b), }; }; From 2cc616f451eaef00b729690390e363d5b41eaafa Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 00:42:21 -0500 Subject: [PATCH 10/22] =?UTF-8?q?=E2=9C=85=20Update=20tests=20to=20use=20r?= =?UTF-8?q?eference=20values,=20clean=20up=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- constants/reference-colors.ts | 5 + constants/wcag-test-values.ts | 162 ++++++++++++++++++++ contrastrast_test.ts | 275 ++++++++++++++++++++++++++-------- 3 files changed, 381 insertions(+), 61 deletions(-) create mode 100644 constants/wcag-test-values.ts diff --git a/constants/reference-colors.ts b/constants/reference-colors.ts index 1b3e65d..6f1e705 100644 --- a/constants/reference-colors.ts +++ b/constants/reference-colors.ts @@ -1,3 +1,8 @@ +/** + * Reference color values for consistent testing + * across the contrastrast library test suite + */ + export type ReferenceColor = { hex: { colorString: string; diff --git a/constants/wcag-test-values.ts b/constants/wcag-test-values.ts new file mode 100644 index 0000000..dd2154f --- /dev/null +++ b/constants/wcag-test-values.ts @@ -0,0 +1,162 @@ +/** + * WCAG contrast test reference values for consistent testing + * across the contrastrast library test suite + */ + +export type WCAGTestValues = { + foreground: string; + background: string; + expectedContrastRatio: number; + testCondition: string; + expectedWCAGResults: { + AA_NORMAL: boolean; + AA_LARGE: boolean; + AAA_NORMAL: boolean; + AAA_LARGE: boolean; + }; +}; + +export const WCAG_CONTRAST_REFERENCE: Record = { + // 8.87:1 AAA Normal and Large test, passes all + allCompliant: { + foreground: "#96fdc1", + background: "#383F34", + expectedContrastRatio: 8.87, + testCondition: "passes all WCAG levels (AAA Normal/Large compliant)", + expectedWCAGResults: { + AA_NORMAL: true, + AA_LARGE: true, + AAA_NORMAL: true, + AAA_LARGE: true, + }, + }, + + // 5.72:1 AAA Large, only AA normal + aaaLargeOnly: { + foreground: "#ffffff", + background: "#845c5c", + expectedContrastRatio: 5.72, + testCondition: "passes AAA Large and AA Normal/Large but fails AAA Normal", + expectedWCAGResults: { + AA_NORMAL: true, + AA_LARGE: true, + AAA_NORMAL: false, + AAA_LARGE: true, + }, + }, + + // 3.75:1 AA Large only, fails AAA Large and all Normal + aaLargeOnly: { + foreground: "#ffffff", + background: "#9c7c7c", + expectedContrastRatio: 3.75, + testCondition: "passes only AA Large, fails all other levels", + expectedWCAGResults: { + AA_NORMAL: false, + AA_LARGE: true, + AAA_NORMAL: false, + AAA_LARGE: false, + }, + }, + + // 2.46:1 non-accessible fails all + nonCompliant: { + foreground: "#865959", + background: "#a2a9b2", + expectedContrastRatio: 2.46, + testCondition: "fails all WCAG levels (non-accessible)", + expectedWCAGResults: { + AA_NORMAL: false, + AA_LARGE: false, + AAA_NORMAL: false, + AAA_LARGE: false, + }, + }, + + // 7.03:1 AAA Normal and Large borderline test + aaaNormalBorderline: { + foreground: "#ffffff", + background: "#4d5a6a", + expectedContrastRatio: 7.03, + testCondition: + "borderline AAA Normal compliance (just above 7:1 threshold)", + expectedWCAGResults: { + AA_NORMAL: true, + AA_LARGE: true, + AAA_NORMAL: true, + AAA_LARGE: true, + }, + }, + + // 3.11:1 - precise boundary case for specific contrast ratio testing + preciseBoundary: { + foreground: "#929292", + background: "#FFFFFF", + expectedContrastRatio: 3.11, + testCondition: + "precise boundary testing (just above AA Large 3:1 threshold)", + expectedWCAGResults: { + AA_NORMAL: false, + AA_LARGE: true, // 3.11:1 passes AA Large (3:1 requirement) + AAA_NORMAL: false, + AAA_LARGE: false, + }, + }, + + // 1:1 - identical colors always fail + identicalColors: { + foreground: "#ff0000", + background: "#ff0000", + expectedContrastRatio: 1.0, + testCondition: "identical colors (1:1 ratio, always fails)", + expectedWCAGResults: { + AA_NORMAL: false, + AA_LARGE: false, + AAA_NORMAL: false, + AAA_LARGE: false, + }, + }, + + // 21:1 - maximum contrast (black vs white) + maximumContrast: { + foreground: "#000000", + background: "#ffffff", + expectedContrastRatio: 21.0, + testCondition: "maximum possible contrast (black vs white, 21:1)", + expectedWCAGResults: { + AA_NORMAL: true, + AA_LARGE: true, + AAA_NORMAL: true, + AAA_LARGE: true, + }, + }, + + // 14.85:1 - midnight blue vs white (from existing reference colors) + midnightBlueWhite: { + foreground: "#191970", + background: "#ffffff", + expectedContrastRatio: 14.85, + testCondition: + "high contrast with reference colors (midnight blue vs white)", + expectedWCAGResults: { + AA_NORMAL: true, + AA_LARGE: true, + AAA_NORMAL: true, + AAA_LARGE: true, + }, + }, + + // 1.61:1 - light gray vs white (low contrast, fails all) + lightGrayWhite: { + foreground: "#CCCCCC", + background: "#ffffff", + expectedContrastRatio: 1.61, + testCondition: "very low contrast (light gray vs white, fails all)", + expectedWCAGResults: { + AA_NORMAL: false, + AA_LARGE: false, + AAA_NORMAL: false, + AAA_LARGE: false, + }, + }, +}; diff --git a/contrastrast_test.ts b/contrastrast_test.ts index 56149e4..7011c55 100644 --- a/contrastrast_test.ts +++ b/contrastrast_test.ts @@ -1,9 +1,10 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import { Contrastrast } from "./contrastrast.ts"; -import { CONTRAST_THRESHOLD, WCAG_LEVELS } from "./constants.ts"; +import { CONTRAST_THRESHOLD } from "./constants.ts"; import type { ContrastResult } from "./utils/textContrast.ts"; import { REFERENCE_COLORS } from "./constants/reference-colors.ts"; +import { WCAG_CONTRAST_REFERENCE } from "./constants/wcag-test-values.ts"; describe("# Contrastrast", () => { describe("## Color parsing", () => { @@ -362,78 +363,65 @@ describe("# Contrastrast", () => { }); it("detailed results show failing combinations", () => { - const lightGray = new Contrastrast( - REFERENCE_COLORS.lightGray.hex.colorString, + const testData = WCAG_CONTRAST_REFERENCE.lightGrayWhite; + const color = new Contrastrast(testData.foreground); + const result = color.textContrast(testData.background, "foreground", { + returnDetails: true, + }) as ContrastResult; + + expect(result.ratio).toBeCloseTo(testData.expectedContrastRatio, 1); + expect(result.passes.AA_NORMAL).toBe( + testData.expectedWCAGResults.AA_NORMAL, + ); + expect(result.passes.AA_LARGE).toBe( + testData.expectedWCAGResults.AA_LARGE, + ); + expect(result.passes.AAA_NORMAL).toBe( + testData.expectedWCAGResults.AAA_NORMAL, + ); + expect(result.passes.AAA_LARGE).toBe( + testData.expectedWCAGResults.AAA_LARGE, ); - const result = lightGray.textContrast( - REFERENCE_COLORS.white.hex.colorString, - "background", - { returnDetails: true }, - ) as ContrastResult; - - expect(result.ratio).toBeCloseTo(1.61, 1); - expect(result.passes.AA_NORMAL).toBe(false); - expect(result.passes.AA_LARGE).toBe(false); - expect(result.passes.AAA_NORMAL).toBe(false); - expect(result.passes.AAA_LARGE).toBe(false); }); }); describe("## WCAG Compliance Helper", () => { - const midnightBlue = new Contrastrast( - REFERENCE_COLORS.midnightBlue.hex.colorString, - ); - const whiteColor = REFERENCE_COLORS.white.hex.colorString; - - it("meetsWCAG returns true for compliant combinations", () => { - expect(midnightBlue.meetsWCAG(whiteColor, "background", "AA", "normal")) - .toBe(true); - expect(midnightBlue.meetsWCAG(whiteColor, "background", "AA", "large")) - .toBe(true); - expect(midnightBlue.meetsWCAG(whiteColor, "background", "AAA", "normal")) - .toBe(true); - expect(midnightBlue.meetsWCAG(whiteColor, "background", "AAA", "large")) - .toBe(true); - }); - - it("meetsWCAG returns false for non-compliant combinations", () => { - const lightGray = new Contrastrast( - REFERENCE_COLORS.lightGray.hex.colorString, - ); - - expect(lightGray.meetsWCAG(whiteColor, "background", "AA", "normal")) - .toBe(false); - expect(lightGray.meetsWCAG(whiteColor, "background", "AA", "large")).toBe( - false, - ); + // Test all WCAG combinations using reference data + Object.entries(WCAG_CONTRAST_REFERENCE).forEach(([_testName, testData]) => { + it(`meetsWCAG ${testData.testCondition} (${testData.expectedContrastRatio}:1)`, () => { + const color = new Contrastrast(testData.foreground); + + expect( + color.meetsWCAG(testData.background, "foreground", "AA", "normal"), + ).toBe(testData.expectedWCAGResults.AA_NORMAL); + expect( + color.meetsWCAG(testData.background, "foreground", "AA", "large"), + ).toBe(testData.expectedWCAGResults.AA_LARGE); + expect( + color.meetsWCAG(testData.background, "foreground", "AAA", "normal"), + ).toBe(testData.expectedWCAGResults.AAA_NORMAL); + expect( + color.meetsWCAG(testData.background, "foreground", "AAA", "large"), + ).toBe(testData.expectedWCAGResults.AAA_LARGE); + }); }); it("meetsWCAG defaults to normal text size", () => { - const resultWithDefault = midnightBlue.meetsWCAG( - whiteColor, - "background", - "AA", + const testData = WCAG_CONTRAST_REFERENCE.aaaNormalBorderline; + const color = new Contrastrast(testData.foreground); + const resultWithDefault = color.meetsWCAG( + testData.background, + "foreground", + "AAA", ); - const resultExplicit = midnightBlue.meetsWCAG( - whiteColor, - "background", - "AA", + const resultExplicit = color.meetsWCAG( + testData.background, + "foreground", + "AAA", "normal", ); expect(resultWithDefault).toBe(resultExplicit); - }); - - it("meetsWCAG uses correct thresholds", () => { - // Use medium gray which should be close to AA normal threshold (4.5) - const mediumGray = new Contrastrast( - REFERENCE_COLORS.mediumGray.hex.colorString, - ); - - const ratio = mediumGray.contrastRatio(whiteColor); - const meetsAA = ratio >= WCAG_LEVELS.AA.normal; - - expect(mediumGray.meetsWCAG(whiteColor, "background", "AA", "normal")) - .toBe(meetsAA); + expect(resultWithDefault).toBe(testData.expectedWCAGResults.AAA_NORMAL); }); }); @@ -531,4 +519,169 @@ describe("# Contrastrast", () => { expect(original.toRgb()).toEqual(originalRgb); }); }); + + describe("## Inverse Conditions", () => { + describe("### Precision & Edge Cases", () => { + it("very similar but different colors are distinguished", () => { + // Test colors that are close but not identical + const color1 = new Contrastrast("#ffffff"); // Pure white + const color2 = new Contrastrast("#fefefe"); // Almost white + + expect(color1.toHex()).not.toBe(color2.toHex()); + expect(color1.toRgb()).not.toEqual(color2.toRgb()); + expect(color1.equals(color2)).toBe(false); + }); + + it("cross-format parsing maintains color differences", () => { + // Parse same color in different formats + const redHex = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); + const redRgb = new Contrastrast(REFERENCE_COLORS.red.rgb.colorString); + const redHsl = new Contrastrast(REFERENCE_COLORS.red.hsl.colorString); + + // Parse different color in same format + const blueHex = new Contrastrast( + REFERENCE_COLORS.midnightBlue.hex.colorString, + ); + + // Same color in different formats should be equal + expect(redHex.equals(redRgb)).toBe(true); + expect(redRgb.equals(redHsl)).toBe(true); + + // Different colors should NOT be equal regardless of format + expect(redHex.equals(blueHex)).toBe(false); + expect(redRgb.equals(blueHex)).toBe(false); + expect(redHsl.equals(blueHex)).toBe(false); + }); + }); + + describe("### Mathematical Properties", () => { + it("isLight and isDark are always opposites", () => { + const testColors = [ + new Contrastrast(REFERENCE_COLORS.black.hex.colorString), + new Contrastrast(REFERENCE_COLORS.white.hex.colorString), + new Contrastrast(REFERENCE_COLORS.red.hex.colorString), + new Contrastrast(REFERENCE_COLORS.lightGray.hex.colorString), + new Contrastrast(REFERENCE_COLORS.mediumGray.hex.colorString), + new Contrastrast(REFERENCE_COLORS.goldenrod.hex.colorString), + new Contrastrast(REFERENCE_COLORS.midnightBlue.hex.colorString), + ]; + + testColors.forEach((color) => { + expect(color.isLight()).toBe(!color.isDark()); + }); + }); + + it("equals is symmetric for all color pairs", () => { + const colors = [ + new Contrastrast(REFERENCE_COLORS.red.hex.colorString), + new Contrastrast(REFERENCE_COLORS.midnightBlue.hex.colorString), + new Contrastrast(REFERENCE_COLORS.white.hex.colorString), + new Contrastrast(REFERENCE_COLORS.black.hex.colorString), + ]; + + // Test all pairs - equals should be symmetric: a.equals(b) === b.equals(a) + for (let i = 0; i < colors.length; i++) { + for (let j = i; j < colors.length; j++) { + const colorA = colors[i]; + const colorB = colors[j]; + expect(colorA.equals(colorB)).toBe(colorB.equals(colorA)); + } + } + }); + }); + + describe("### Threshold & Boundary Testing", () => { + it("brightness exactly at threshold (124) behaves consistently", () => { + // Create a color with brightness exactly at threshold (124) + // Using formula: (r * 299 + g * 587 + b * 114) / 1000 = 124 + // Solving: r=124, g=124, b=124 gives brightness = 124 + const thresholdColor = Contrastrast.fromRgb(124, 124, 124); + + expect(thresholdColor.brightness()).toBe(CONTRAST_THRESHOLD); + // At exactly threshold, should NOT be light (uses > not >=) + expect(thresholdColor.isLight()).toBe(false); + expect(thresholdColor.isDark()).toBe(true); + }); + + it("WCAG levels maintain logical relationships", () => { + // Test that AAA is stricter than AA, and Normal is stricter than Large + const color = new Contrastrast("#ffffff"); + const mediumContrast = "#666666"; // Medium contrast color + + const AALarge = color.meetsWCAG( + mediumContrast, + "background", + "AA", + "large", + ); + const AANormal = color.meetsWCAG( + mediumContrast, + "background", + "AA", + "normal", + ); + const AAALarge = color.meetsWCAG( + mediumContrast, + "background", + "AAA", + "large", + ); + const AAANormal = color.meetsWCAG( + mediumContrast, + "background", + "AAA", + "normal", + ); + + // Logical relationships that should always hold: + // If AAA Normal passes, AAA Large should also pass + if (AAANormal) expect(AAALarge).toBe(true); + // If AAA Large passes, AA Large should also pass + if (AAALarge) expect(AALarge).toBe(true); + // If AA Normal passes, AA Large should also pass + if (AANormal) expect(AALarge).toBe(true); + }); + }); + + describe("### Deterministic Behavior", () => { + it("luminance values stay within valid range", () => { + const testColors = [ + new Contrastrast(REFERENCE_COLORS.black.hex.colorString), + new Contrastrast(REFERENCE_COLORS.white.hex.colorString), + new Contrastrast(REFERENCE_COLORS.red.hex.colorString), + new Contrastrast(REFERENCE_COLORS.midnightBlue.hex.colorString), + ]; + + testColors.forEach((color) => { + const luminance = color.luminance(); + + // Luminance must be between 0 and 1 (inclusive) + expect(luminance).toBeGreaterThanOrEqual(0); + expect(luminance).toBeLessThanOrEqual(1); + + // Multiple calls should return same value + expect(color.luminance()).toBe(luminance); + }); + }); + + it("identical colors always fail WCAG tests", () => { + const testData = WCAG_CONTRAST_REFERENCE.identicalColors; + const color = new Contrastrast(testData.foreground); + + // Same color should always fail (contrast ratio = 1:1) + expect( + color.meetsWCAG(testData.background, "foreground", "AA", "normal"), + ).toBe(testData.expectedWCAGResults.AA_NORMAL); + expect( + color.meetsWCAG(testData.background, "foreground", "AA", "large"), + ).toBe(testData.expectedWCAGResults.AA_LARGE); + expect( + color.meetsWCAG(testData.background, "foreground", "AAA", "normal"), + ).toBe(testData.expectedWCAGResults.AAA_NORMAL); + expect( + color.meetsWCAG(testData.background, "foreground", "AAA", "large"), + ).toBe(testData.expectedWCAGResults.AAA_LARGE); + }); + }); + }); }); From 619f5bb5a5a6e0151a6a112659bf28cf48241631 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 00:42:39 -0500 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=93=9D=20add=20jsdoc=20for=20all=20?= =?UTF-8?q?methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrastrast.ts | 218 +++++++++++++++++++++++++++++++++- helpers/colorStringParsers.ts | 13 ++ 2 files changed, 228 insertions(+), 3 deletions(-) diff --git a/contrastrast.ts b/contrastrast.ts index 015baf9..3e23676 100644 --- a/contrastrast.ts +++ b/contrastrast.ts @@ -17,21 +17,77 @@ import { import type { HSLValues, RGBValues } from "./types/Colors.types.ts"; import type { WCAGContrastLevel, WCAGTextSize } from "./types/WCAG.types.ts"; +/** + * A comprehensive color manipulation class that supports parsing, conversion, and accessibility analysis + * @example + * ```typescript + * const color = new Contrastrast("#1a73e8"); + * const isLight = color.isLight(); // false + * const hexValue = color.toHex(); // "#1a73e8" + * const ratio = color.contrastRatio("#ffffff"); // 4.5 + * ``` + */ export class Contrastrast { private readonly rgb: RGBValues; + /** + * Create a new Contrastrast instance from a color string + * @param colorString Color string in hex (#abc or #abcdef), rgb (rgb(r,g,b)), or hsl (hsl(h,s%,l%)) format + * @throws {Error} When the color string format is not supported + * @example + * ```typescript + * const color1 = new Contrastrast("#ff0000"); + * const color2 = new Contrastrast("rgb(255, 0, 0)"); + * const color3 = new Contrastrast("hsl(0, 100%, 50%)"); + * ``` + */ constructor(colorString: string) { this.rgb = getRGBFromColorString(colorString); } // Parser/Creator Methods + /** + * Create a Contrastrast instance from a hex color string + * @param hex Hex color string with or without # prefix (e.g., "#ff0000" or "ff0000") + * @returns New Contrastrast instance + * @example + * ```typescript + * const red1 = Contrastrast.fromHex("#ff0000"); + * const red2 = Contrastrast.fromHex("ff0000"); + * const shortRed = Contrastrast.fromHex("#f00"); + * ``` + */ static fromHex = (hex: string): Contrastrast => { const normalizedHex = hex.startsWith("#") ? hex : `#${hex}`; return new Contrastrast(normalizedHex); }; + /** + * Create a Contrastrast instance from RGB values as separate parameters + * @param r Red value (0-255) + * @param g Green value (0-255) + * @param b Blue value (0-255) + * @returns New Contrastrast instance + */ static fromRgb(r: number, g: number, b: number): Contrastrast; + /** + * Create a Contrastrast instance from an RGB values object + * @param rgb RGB values object with r, g, b properties + * @returns New Contrastrast instance + */ static fromRgb(rgb: RGBValues): Contrastrast; + /** + * Create a Contrastrast instance from RGB values + * @param rOrRgb Either red value (0-255) or RGB values object + * @param g Green value (0-255) when first parameter is red value + * @param b Blue value (0-255) when first parameter is red value + * @returns New Contrastrast instance + * @example + * ```typescript + * const red1 = Contrastrast.fromRgb(255, 0, 0); + * const red2 = Contrastrast.fromRgb({ r: 255, g: 0, b: 0 }); + * ``` + */ static fromRgb( rOrRgb: number | RGBValues, g?: number, @@ -43,8 +99,32 @@ export class Contrastrast { return new Contrastrast(`rgb(${rOrRgb}, ${g}, ${b})`); } + /** + * Create a Contrastrast instance from HSL values as separate parameters + * @param h Hue value (0-360 degrees) + * @param s Saturation value (0-100 percent) + * @param l Lightness value (0-100 percent) + * @returns New Contrastrast instance + */ static fromHsl(h: number, s: number, l: number): Contrastrast; + /** + * Create a Contrastrast instance from an HSL values object + * @param hsl HSL values object with h, s, l properties + * @returns New Contrastrast instance + */ static fromHsl(hsl: HSLValues): Contrastrast; + /** + * Create a Contrastrast instance from HSL values + * @param hOrHsl Either hue value (0-360) or HSL values object + * @param s Saturation value (0-100) when first parameter is hue value + * @param l Lightness value (0-100) when first parameter is hue value + * @returns New Contrastrast instance + * @example + * ```typescript + * const red1 = Contrastrast.fromHsl(0, 100, 50); + * const red2 = Contrastrast.fromHsl({ h: 0, s: 100, l: 50 }); + * ``` + */ static fromHsl( hOrHsl: number | HSLValues, s?: number, @@ -56,10 +136,30 @@ export class Contrastrast { return new Contrastrast(`hsl(${hOrHsl}, ${s}%, ${l}%)`); } + /** + * Parse a color string into a Contrastrast instance (alias for constructor) + * @param colorString Color string in hex, rgb, or hsl format + * @returns New Contrastrast instance + * @example + * ```typescript + * const color = Contrastrast.parse("#1a73e8"); + * ``` + */ static parse = (colorString: string): Contrastrast => new Contrastrast(colorString); // Conversion & Output Methods + /** + * Convert the color to a hex string representation + * @param includeHash Whether to include the # prefix (default: true) + * @returns Hex color string (e.g., "#ff0000" or "ff0000") + * @example + * ```typescript + * const color = new Contrastrast("rgb(255, 0, 0)"); + * const withHash = color.toHex(); // "#ff0000" + * const withoutHash = color.toHex(false); // "ff0000" + * ``` + */ toHex = (includeHash: boolean = true): string => { const toHex = (n: number) => { const hex = Math.round(n).toString(16); @@ -70,11 +170,38 @@ export class Contrastrast { return includeHash ? `#${hexValue}` : hexValue; }; + /** + * Get the RGB values as an object + * @returns RGB values object with r, g, b properties (0-255) + * @example + * ```typescript + * const color = new Contrastrast("#ff0000"); + * const rgb = color.toRgb(); // { r: 255, g: 0, b: 0 } + * ``` + */ toRgb = (): RGBValues => ({ ...this.rgb }); + /** + * Convert the color to an RGB string representation + * @returns RGB color string (e.g., "rgb(255, 0, 0)") + * @example + * ```typescript + * const color = new Contrastrast("#ff0000"); + * const rgbString = color.toRgbString(); // "rgb(255, 0, 0)" + * ``` + */ toRgbString = (): string => `rgb(${this.rgb.r}, ${this.rgb.g}, ${this.rgb.b})`; + /** + * Convert the color to HSL values + * @returns HSL values object with h (0-360), s (0-100), l (0-100) properties + * @example + * ```typescript + * const color = new Contrastrast("#ff0000"); + * const hsl = color.toHsl(); // { h: 0, s: 100, l: 50 } + * ``` + */ toHsl = (): HSLValues => { const r = this.rgb.r / RGB_BOUNDS.MAX; const g = this.rgb.g / RGB_BOUNDS.MAX; @@ -116,12 +243,31 @@ export class Contrastrast { }; }; + /** + * Convert the color to an HSL string representation + * @returns HSL color string (e.g., "hsl(0, 100%, 50%)") + * @example + * ```typescript + * const color = new Contrastrast("#ff0000"); + * const hslString = color.toHslString(); // "hsl(0, 100%, 50%)" + * ``` + */ toHslString = (): string => { const hsl = this.toHsl(); return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`; }; - // WCAG 2.1 luminance calculation + /** + * Calculate the WCAG 2.1 relative luminance of the color + * @returns Luminance value between 0 (darkest) and 1 (lightest) + * @example + * ```typescript + * const black = new Contrastrast("#000000"); + * const white = new Contrastrast("#ffffff"); + * console.log(black.luminance()); // 0 + * console.log(white.luminance()); // 1 + * ``` + */ luminance = (): number => { const gammaCorrect = (colorValue: number): number => { const c = colorValue / RGB_BOUNDS.MAX; @@ -145,6 +291,15 @@ export class Contrastrast { ); }; + /** + * Calculate the perceived brightness of the color using the WCAG formula + * @returns Brightness value between 0 (darkest) and 255 (brightest) + * @example + * ```typescript + * const color = new Contrastrast("#1a73e8"); + * const brightness = color.brightness(); // ~102.4 + * ``` + */ brightness = (): number => (this.rgb.r * BRIGHTNESS_COEFFICIENTS.RED + this.rgb.g * BRIGHTNESS_COEFFICIENTS.GREEN + @@ -152,10 +307,42 @@ export class Contrastrast { BRIGHTNESS_COEFFICIENTS.DIVISOR; /* Utility Methods */ + /** + * Determine if the color is considered "light" based on WCAG brightness threshold + * @returns True if the color is light (brightness > 124), false otherwise + * @example + * ```typescript + * const lightColor = new Contrastrast("#ffffff"); + * const darkColor = new Contrastrast("#000000"); + * console.log(lightColor.isLight()); // true + * console.log(darkColor.isLight()); // false + * ``` + */ isLight = (): boolean => this.brightness() > CONTRAST_THRESHOLD; + /** + * Determine if the color is considered "dark" based on WCAG brightness threshold + * @returns True if the color is dark (brightness <= 124), false otherwise + * @example + * ```typescript + * const lightColor = new Contrastrast("#ffffff"); + * const darkColor = new Contrastrast("#000000"); + * console.log(lightColor.isDark()); // false + * console.log(darkColor.isDark()); // true + * ``` + */ isDark = (): boolean => !this.isLight(); + /** + * Calculate the WCAG 2.1 contrast ratio between this color and another color + * @param color Color to compare against - accepts hex, rgb, hsl strings or Contrastrast instance + * @returns Contrast ratio from 1:1 (no contrast) to 21:1 (maximum contrast) + * @example + * ```typescript + * const bgColor = new Contrastrast("#1a73e8"); + * const ratio = bgColor.contrastRatio("#ffffff"); // 4.5 + * ``` + */ contrastRatio = (color: Contrastrast | string): number => contrastRatio(this, color); @@ -231,7 +418,20 @@ export class Contrastrast { } } - // WCAG Compliance Helper + /** + * Check if the color combination meets specific WCAG contrast requirements + * @param comparisonColor Color to compare against - accepts hex, rgb, hsl strings or Contrastrast instance + * @param role Role of this color instance ("foreground" for text color, "background" for background color) + * @param targetWcagLevel Target WCAG compliance level ("AA" or "AAA") + * @param textSize Text size category ("normal" or "large") - affects required contrast ratio + * @returns True if the combination meets the specified WCAG requirements + * @example + * ```typescript + * const bgColor = new Contrastrast("#1a73e8"); + * const meetsAA = bgColor.meetsWCAG("#ffffff", "background", "AA"); // true + * const meetsAAA = bgColor.meetsWCAG("#ffffff", "background", "AAA"); // false + * ``` + */ meetsWCAG = ( comparisonColor: Contrastrast | string, role: "foreground" | "background", @@ -243,7 +443,19 @@ export class Contrastrast { return ratio >= required; }; - // Utility Methods + /** + * Check if this color is equal to another color (RGB values comparison) + * @param color Color to compare against - accepts hex, rgb, hsl strings or Contrastrast instance + * @returns True if both colors have identical RGB values + * @example + * ```typescript + * const color1 = new Contrastrast("#ff0000"); + * const color2 = new Contrastrast("rgb(255, 0, 0)"); + * const color3 = new Contrastrast("hsl(0, 100%, 50%)"); + * console.log(color1.equals(color2)); // true + * console.log(color1.equals(color3)); // true + * ``` + */ equals = (color: Contrastrast | string): boolean => { const other = color instanceof Contrastrast ? color diff --git a/helpers/colorStringParsers.ts b/helpers/colorStringParsers.ts index 7ef460c..edf4286 100644 --- a/helpers/colorStringParsers.ts +++ b/helpers/colorStringParsers.ts @@ -14,6 +14,19 @@ const HEXCOLOR_REGEX = /^#?([a-f0-9]{6}|[a-f0-9]{3})$/i; const HSL_REGEX = /hsl\(\s*((?:360|3[0-5][0-9]|2[0-9][0-9]|1[0-9][0-9]|(?:100|0{0,1}[0-9][0-9]|0{0,1}0{0,1}[0-9])))(?:°|deg){0,1}\s*,{0,1}\s*((?:100|0{0,1}[0-9][0-9]|0{0,1}0{0,1}[0-9])(?:\.\d+)?)%{0,1}\s*,{0,1}\s*((?:100|0{0,1}[0-9][0-9]|0{0,1}0{0,1}[0-9])(?:\.\d+)?)%{0,1}\)/i; +/** + * Parse a color string and extract RGB values + * Supports hex ("#abc", "#abcdef"), rgb ("rgb(r,g,b)"), and hsl ("hsl(h,s%,l%)") format strings + * @param colorString Color string in supported format + * @returns RGB values object with r, g, b properties (0-255) + * @throws {Error} When the color string format is not supported or invalid + * @example + * ```typescript + * const rgb1 = getRGBFromColorString("#ff0000"); // { r: 255, g: 0, b: 0 } + * const rgb2 = getRGBFromColorString("rgb(255, 0, 0)"); // { r: 255, g: 0, b: 0 } + * const rgb3 = getRGBFromColorString("hsl(0, 100%, 50%)"); // { r: 255, g: 0, b: 0 } + * ``` + */ export const getRGBFromColorString = (colorString: string): RGBValues => { const [fullRgbMatch, red, green, blue] = colorString.match(RGB_REGEX) || []; if (fullRgbMatch) { From a84eb80fbb076a2c43d015c01732d9459be7e657 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 16:08:53 -0500 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=94=A7=20Add=20config=20options=20f?= =?UTF-8?q?or=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrastrast.ts | 48 +++++++++++++++++++++++++++++++---- helpers/colorStringParsers.ts | 2 +- types/ParseOptions.types.ts | 4 +++ 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 types/ParseOptions.types.ts diff --git a/contrastrast.ts b/contrastrast.ts index 3e23676..acb2cdc 100644 --- a/contrastrast.ts +++ b/contrastrast.ts @@ -16,6 +16,12 @@ import { } from "./utils/textContrast.ts"; import type { HSLValues, RGBValues } from "./types/Colors.types.ts"; import type { WCAGContrastLevel, WCAGTextSize } from "./types/WCAG.types.ts"; +import type { ParseOptions } from "./types/ParseOptions.types.ts"; + +const DEFAULT_PARSE_OPTIONS: Required = { + throwOnError: true, + fallbackColor: "#000000", +}; /** * A comprehensive color manipulation class that supports parsing, conversion, and accessibility analysis @@ -33,19 +39,42 @@ export class Contrastrast { /** * Create a new Contrastrast instance from a color string * @param colorString Color string in hex (#abc or #abcdef), rgb (rgb(r,g,b)), or hsl (hsl(h,s%,l%)) format - * @throws {Error} When the color string format is not supported + * @param parseOpts Optional parsing configuration + * @param parseOpts.throwOnError Whether to throw an error on invalid color strings (default: true) + * @param parseOpts.fallbackColor Fallback color to use when throwOnError is false (default: "#000000") + * @throws {Error} When the color string format is not supported and throwOnError is true * @example * ```typescript * const color1 = new Contrastrast("#ff0000"); * const color2 = new Contrastrast("rgb(255, 0, 0)"); * const color3 = new Contrastrast("hsl(0, 100%, 50%)"); + * + * // With error handling + * const color4 = new Contrastrast("invalid", { throwOnError: false, fallbackColor: "#333333" }); * ``` */ - constructor(colorString: string) { - this.rgb = getRGBFromColorString(colorString); + constructor(colorString: string, parseOpts?: Partial) { + const options = parseOpts || DEFAULT_PARSE_OPTIONS; + try { + this.rgb = getRGBFromColorString(colorString); + } catch { + if (options.throwOnError === false) { + console.warn( + `Invalid color string "${colorString}"; Using "${ + options.fallbackColor || DEFAULT_PARSE_OPTIONS.fallbackColor + }" as fallback color`, + ); + this.rgb = getRGBFromColorString( + options.fallbackColor || DEFAULT_PARSE_OPTIONS.fallbackColor, + ); + } else { + throw Error(`Invalid color string "${colorString}"`); + } + } } // Parser/Creator Methods + /** * Create a Contrastrast instance from a hex color string * @param hex Hex color string with or without # prefix (e.g., "#ff0000" or "ff0000") @@ -139,14 +168,23 @@ export class Contrastrast { /** * Parse a color string into a Contrastrast instance (alias for constructor) * @param colorString Color string in hex, rgb, or hsl format + * @param parseOpts Optional parsing configuration + * @param parseOpts.throwOnError Whether to throw an error on invalid color strings (default: true) + * @param parseOpts.fallbackColor Fallback color to use when throwOnError is false (default: "#000000") * @returns New Contrastrast instance + * @throws {Error} When the color string format is not supported and throwOnError is true * @example * ```typescript * const color = Contrastrast.parse("#1a73e8"); + * + * // With error handling + * const safeColor = Contrastrast.parse("invalid", { throwOnError: false, fallbackColor: "#ffffff" }); * ``` */ - static parse = (colorString: string): Contrastrast => - new Contrastrast(colorString); + static parse = ( + colorString: string, + parseOpts?: Partial, + ): Contrastrast => new Contrastrast(colorString, parseOpts); // Conversion & Output Methods /** diff --git a/helpers/colorStringParsers.ts b/helpers/colorStringParsers.ts index edf4286..3e8d8fc 100644 --- a/helpers/colorStringParsers.ts +++ b/helpers/colorStringParsers.ts @@ -44,5 +44,5 @@ export const getRGBFromColorString = (colorString: string): RGBValues => { return extractRGBValuesFromHSL(hue, saturation, light); } - throw new Error(`Unsupported color string "${colorString}"`); + throw new Error(`Invalid color string "${colorString}"`); }; diff --git a/types/ParseOptions.types.ts b/types/ParseOptions.types.ts new file mode 100644 index 0000000..ec72e55 --- /dev/null +++ b/types/ParseOptions.types.ts @@ -0,0 +1,4 @@ +export type ParseOptions = { + throwOnError: boolean; + fallbackColor?: string; +}; From 72cb63034a5054cfc0951d636dd206f69bef753b Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 16:15:23 -0500 Subject: [PATCH 13/22] =?UTF-8?q?=E2=9C=85=20Add=20tests=20for=20new=20par?= =?UTF-8?q?seopts=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrastrast_test.ts | 150 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/contrastrast_test.ts b/contrastrast_test.ts index 7011c55..104bfd9 100644 --- a/contrastrast_test.ts +++ b/contrastrast_test.ts @@ -177,6 +177,156 @@ describe("# Contrastrast", () => { expect(color.toHex()).toBe(originalHex); }); }); + + describe("### ParseOptions configuration", () => { + describe("#### throwOnError behavior", () => { + it("constructor throws by default on invalid color string", () => { + expect(() => new Contrastrast("invalid-color")).toThrow( + 'Invalid color string "invalid-color"', + ); + }); + + it("constructor throws when throwOnError is explicitly true", () => { + expect(() => + new Contrastrast("invalid-color", { throwOnError: true }) + ).toThrow('Invalid color string "invalid-color"'); + }); + + it("constructor does not throw when throwOnError is false", () => { + expect(() => + new Contrastrast("invalid-color", { throwOnError: false }) + ).not.toThrow(); + }); + + it("static parse throws by default on invalid color string", () => { + expect(() => Contrastrast.parse("invalid-color")).toThrow( + 'Invalid color string "invalid-color"', + ); + }); + + it("static parse throws when throwOnError is explicitly true", () => { + expect(() => + Contrastrast.parse("invalid-color", { throwOnError: true }) + ).toThrow('Invalid color string "invalid-color"'); + }); + + it("static parse does not throw when throwOnError is false", () => { + expect(() => + Contrastrast.parse("invalid-color", { throwOnError: false }) + ).not.toThrow(); + }); + }); + + describe("#### fallbackColor behavior", () => { + it("constructor uses default fallback color (#000000) when throwOnError is false", () => { + const color = new Contrastrast("invalid-color", { + throwOnError: false, + }); + expect(color.toHex()).toBe("#000000"); + expect(color.toRgb()).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it("constructor uses custom fallbackColor when provided", () => { + const fallbackColor = "#ff0000"; + const color = new Contrastrast("invalid-color", { + throwOnError: false, + fallbackColor, + }); + expect(color.toHex()).toBe(fallbackColor); + expect(color.toRgb()).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it("static parse uses default fallback color when throwOnError is false", () => { + const color = Contrastrast.parse("invalid-color", { + throwOnError: false, + }); + expect(color.toHex()).toBe("#000000"); + expect(color.toRgb()).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it("static parse uses custom fallbackColor when provided", () => { + const fallbackColor = "#00ff00"; + const color = Contrastrast.parse("invalid-color", { + throwOnError: false, + fallbackColor, + }); + expect(color.toHex()).toBe(fallbackColor); + expect(color.toRgb()).toEqual({ r: 0, g: 255, b: 0 }); + }); + + it("fallbackColor works with RGB format", () => { + const fallbackColor = "rgb(128, 128, 128)"; + const color = new Contrastrast("not-a-color", { + throwOnError: false, + fallbackColor, + }); + expect(color.toRgb()).toEqual({ r: 128, g: 128, b: 128 }); + }); + + it("fallbackColor works with HSL format", () => { + const fallbackColor = "hsl(120, 100%, 50%)"; // Pure green + const color = Contrastrast.parse("gibberish", { + throwOnError: false, + fallbackColor, + }); + expect(color.toRgb()).toEqual({ r: 0, g: 255, b: 0 }); + }); + + it("invalid fallbackColor throws error even when throwOnError is false", () => { + expect(() => + new Contrastrast("invalid-color", { + throwOnError: false, + fallbackColor: "not-a-valid-color", + }) + ).toThrow(); + expect(() => + Contrastrast.parse("invalid-color", { + throwOnError: false, + fallbackColor: "also-invalid", + }) + ).toThrow(); + }); + }); + + describe("#### Edge cases and validation", () => { + it("throwOnError false with undefined fallbackColor uses default", () => { + const color = new Contrastrast("invalid", { + throwOnError: false, + fallbackColor: undefined, + }); + expect(color.toHex()).toBe("#000000"); + }); + + it("valid color string ignores ParseOptions", () => { + const validColor = "#ff0000"; + const color1 = new Contrastrast(validColor); + const color2 = new Contrastrast(validColor, { + throwOnError: false, + fallbackColor: "#00ff00", + }); + + expect(color1.equals(color2)).toBe(true); + expect(color2.toHex()).toBe(validColor); + }); + + it("constructor with empty parseOpts object behaves like defaults", () => { + expect(() => new Contrastrast("invalid", {})).toThrow( + 'Invalid color string "invalid"', + ); + }); + + it("parseOpts as undefined behaves like defaults", () => { + expect(() => new Contrastrast("invalid", undefined)).toThrow( + 'Invalid color string "invalid"', + ); + }); + + it("partially filled parseOpts works correctly", () => { + const color = new Contrastrast("invalid", { throwOnError: false }); // No fallbackColor specified + expect(color.toHex()).toBe("#000000"); // Should use default fallback + }); + }); + }); }); describe("## Conversion Methods", () => { From 8be58b166dc2d2df2ab9981fe5039d82861831b4 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 18:40:58 -0500 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=9A=9A=20Move=20reference=20color?= =?UTF-8?q?=20files=20to=20more=20appropriate=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrastrast_test.ts | 4 ++-- {constants => reference-values}/reference-colors.ts | 0 .../wcag-reference-colors.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename {constants => reference-values}/reference-colors.ts (100%) rename constants/wcag-test-values.ts => reference-values/wcag-reference-colors.ts (100%) diff --git a/contrastrast_test.ts b/contrastrast_test.ts index 104bfd9..7182764 100644 --- a/contrastrast_test.ts +++ b/contrastrast_test.ts @@ -3,8 +3,8 @@ import { describe, it } from "@std/testing/bdd"; import { Contrastrast } from "./contrastrast.ts"; import { CONTRAST_THRESHOLD } from "./constants.ts"; import type { ContrastResult } from "./utils/textContrast.ts"; -import { REFERENCE_COLORS } from "./constants/reference-colors.ts"; -import { WCAG_CONTRAST_REFERENCE } from "./constants/wcag-test-values.ts"; +import { REFERENCE_COLORS } from "./reference-values/reference-colors.ts"; +import { WCAG_CONTRAST_REFERENCE } from "./reference-values/wcag-reference-colors.ts"; describe("# Contrastrast", () => { describe("## Color parsing", () => { diff --git a/constants/reference-colors.ts b/reference-values/reference-colors.ts similarity index 100% rename from constants/reference-colors.ts rename to reference-values/reference-colors.ts diff --git a/constants/wcag-test-values.ts b/reference-values/wcag-reference-colors.ts similarity index 100% rename from constants/wcag-test-values.ts rename to reference-values/wcag-reference-colors.ts From e9ace55ed511a002db365a7ff9e257743f4dd81e Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 19:10:38 -0500 Subject: [PATCH 15/22] =?UTF-8?q?=F0=9F=93=9D=20Update=20README,=20add=20s?= =?UTF-8?q?ome=20type=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 499 ++++++++++++++++++++++++++++++++---- types/Colors.types.ts | 12 + types/ParseOptions.types.ts | 5 + types/WCAG.types.ts | 6 + utils/textContrast.ts | 13 + 5 files changed, 478 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 2798894..307ccdc 100644 --- a/README.md +++ b/README.md @@ -3,115 +3,500 @@ [![JSR](https://jsr.io/badges/@amuench/contrastrast)](https://jsr.io/@amuench/contrastrast) [![npm version](https://badge.fury.io/js/contrastrast.svg)](https://badge.fury.io/js/contrastrast) -# constrastrast +# contrastrast -A lightweight tool that parses color strings and recommends text contrast based -on [WCAG Standards](http://www.w3.org/TR/AERT#color-contrast) +A comprehensive TypeScript/Deno library for color manipulation, parsing, +conversion, and accessibility analysis. Built with WCAG standards in mind, +contrastrast helps you create accessible color combinations and analyze contrast +ratios. + +**Features:** + +- 🎨 **Multi-format parsing**: Supports HEX, RGB, and HSL color formats +- ♿ **WCAG compliance**: Built-in WCAG 2.1 contrast ratio calculations and + compliance checking +- 📊 **Color analysis**: Luminance, brightness, and accessibility calculations +- 🔄 **Format conversion**: Convert between HEX, RGB, and HSL formats +- ⚡ **TypeScript**: Full TypeScript support with comprehensive type definitions ## Installation -Install `constrastrast` by running one of the following commands: +Install `contrastrast` by running one of the following commands: ```bash -npm install --save constrastrast +npm install --save contrastrast -yarn add constrastrast +yarn add contrastrast -pnpm install --save constrastrast +pnpm install --save contrastrast -deno add contrastrast +deno add jsr:@amuench/contrastrast ``` -## How it works +## Quick Start -`constrastrast` takes a given background color as a string in either HEX, HSL, -or RGB format, and (by default) returns `"dark"` or `"light"` as a recommended -text variant for that given background color +```typescript +import { Contrastrast } from "contrastrast"; -For example, you may use it like this: +// Parse any color format, by default will throw an error if the color string is invalid +const color = new Contrastrast("#1a73e8"); -```tsx -import { textContrastForBGColor } from "contrastrast"; +// Check if the color is light or dark +console.log(color.isLight()); // false +console.log(color.isDark()); // true -const MyColorChangingComponent = (backgroundColor: string) => { - return
- This text is readable no matter what the background color is! -
-} +// Get contrast ratio with another color +const ratio = color.contrastRatio("#ffffff"); // 4.5 + +// Check WCAG compliance +const meetsAA = color.meetsWCAG("#ffffff", "background", "AA"); // true + +// Convert between formats +console.log(color.toHex()); // "#1a73e8" +console.log(color.toRgbString()); // "rgb(26, 115, 232)" +console.log(color.toHslString()); // "hsl(218, 80%, 51%)" ``` -## Supported Color Formats +## API Reference -`constrastrast` supports the following color string formats: +### Types -### HEX +The library exports the following TypeScript types: -HEX Notation in either 3 or 6 length format +#### Color Value Types -**examples** +```typescript +type RGBValues = { + r: number; // Red (0-255) + g: number; // Green (0-255) + b: number; // Blue (0-255) +}; +type HSLValues = { + h: number; // Hue (0-360) + s: number; // Saturation (0-100) + l: number; // Lightness (0-100) +}; ``` -#ad1232 -ad1232 +#### Configuration Types -#ada +```typescript +type ParseOptions = { + throwOnError: boolean; // Whether to throw on invalid colors + fallbackColor?: string; // Fallback color when throwOnError is false +}; -ada +type ContrastOptions = { + returnDetails?: boolean; // Return detailed WCAG analysis instead of just ratio +}; ``` -### RGB +#### Result Types + +```typescript +type ContrastResult = { + ratio: number; + passes: { + AA_NORMAL: boolean; // WCAG AA normal text (4.5:1) + AA_LARGE: boolean; // WCAG AA large text (3:1) + AAA_NORMAL: boolean; // WCAG AAA normal text (7:1) + AAA_LARGE: boolean; // WCAG AAA large text (4.5:1) + }; +}; +``` -Standard RGB notation +#### WCAG Types -**examples** +```typescript +type WCAGContrastLevel = "AA" | "AAA"; +type WCAGTextSize = "normal" | "large"; +``` + +### Constructor and Factory Methods + +#### `new Contrastrast(colorString: string, parseOpts?: Partial)` + +Create a new Contrastrast instance from any supported color string. + +```typescript +const color1 = new Contrastrast("#ff0000"); +const color2 = new Contrastrast("rgb(255, 0, 0)"); +const color3 = new Contrastrast("hsl(0, 100%, 50%)"); +// With error handling +const safeColor = new Contrastrast("invalid-color", { + throwOnError: false, + fallbackColor: "#000000", +}); ``` -rgb(100,200, 230) -rgb(5, 30, 40) +#### `Contrastrast.parse(colorString: string, parseOpts?: Partial): Contrastrast` + +Static method alias for the constructor. Also accepts the same `parseOpts` +configuration object. + +```typescript +const color = Contrastrast.parse("#1a73e8"); + +// With error handling +const safeColor = Contrastrast.parse("invalid-color", { + throwOnError: false, + fallbackColor: "#ffffff", +}); ``` -### HSL +#### `Contrastrast.fromHex(hex: string): Contrastrast` + +Create from hex color (with or without #). Supports 3 and 6 digit codes. + +```typescript +const red1 = Contrastrast.fromHex("#ff0000"); +const red2 = Contrastrast.fromHex("ff0000"); +const shortRed = Contrastrast.fromHex("#f00"); +``` + +#### `Contrastrast.fromRgb(r: number, g: number, b: number): Contrastrast` / `Contrastrast.fromRgb(rgb: RGBValues): Contrastrast` + +Create from RGB values. + +```typescript +const red1 = Contrastrast.fromRgb(255, 0, 0); +const red2 = Contrastrast.fromRgb({ r: 255, g: 0, b: 0 }); +``` + +#### `Contrastrast.fromHsl(h: number, s: number, l: number): Contrastrast` / `Contrastrast.fromHsl(hsl: HSLValues): Contrastrast` + +Create from HSL values. + +```typescript +const red1 = Contrastrast.fromHsl(0, 100, 50); +const red2 = Contrastrast.fromHsl({ h: 0, s: 100, l: 50 }); +``` + +### Color Format Conversion + +#### `toHex(includeHash?: boolean): string` + +Convert to hex format. + +```typescript +const color = new Contrastrast("rgb(255, 0, 0)"); +console.log(color.toHex()); // "#ff0000" +console.log(color.toHex(false)); // "ff0000" +``` + +#### `toRgb(): RGBValues` + +Get RGB values as an object. + +```typescript +const rgb = color.toRgb(); // { r: 255, g: 0, b: 0 } +``` + +#### `toRgbString(): string` + +Convert to RGB string format. + +```typescript +const rgbString = color.toRgbString(); // "rgb(255, 0, 0)" +``` + +#### `toHsl(): HSLValues` + +Get HSL values as an object. + +```typescript +const hsl = color.toHsl(); // { h: 0, s: 100, l: 50 } +``` + +#### `toHslString(): string` + +Convert to HSL string format. + +```typescript +const hslString = color.toHslString(); // "hsl(0, 100%, 50%)" +``` + +### Color Analysis + +#### `luminance(): number` + +Calculate +[WCAG 2.1 relative luminance](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-relative-luminance) +(0-1). + +```typescript +const black = new Contrastrast("#000000"); +const white = new Contrastrast("#ffffff"); +console.log(black.luminance()); // 0 +console.log(white.luminance()); // 1 +``` + +#### `brightness(): number` + +Calculate perceived brightness using +[AERT formula](https://www.w3.org/TR/AERT#color-contrast) (0-255). + +```typescript +const color = new Contrastrast("#1a73e8"); +const brightness = color.brightness(); // ~102.4 +``` + +#### `isLight(): boolean` / `isDark(): boolean` -HSL Notation with or without the symbol markers +Determine if color is light or dark based on +[AERT brightness](https://www.w3.org/TR/AERT#color-contrast) threshold (124). -**examples** +```typescript +const lightColor = new Contrastrast("#ffffff"); +const darkColor = new Contrastrast("#000000"); +console.log(lightColor.isLight()); // true +console.log(darkColor.isDark()); // true +``` + +### Accessibility & Contrast + +#### `contrastRatio(color: Contrastrast | string): number` +Calculate +[WCAG 2.1 contrast ratio](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio) +between colors. + +```typescript +const bgColor = new Contrastrast("#1a73e8"); +const ratio = bgColor.contrastRatio("#ffffff"); // 4.5 ``` -hsl(217°, 90%, 61%) -hsl(72°, 90%, 61%) +#### `textContrast(comparisonColor: Contrastrast | string, role?: "foreground" | "background", options?: ContrastOptions): number | ContrastResult` + +Calculate contrast with detailed +[WCAG compliance analysis](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) +options. + +```typescript +// Simple ratio +const ratio = bgColor.textContrast("#ffffff"); // 4.5 + +// Detailed analysis +const result = bgColor.textContrast("#ffffff", "background", { + returnDetails: true, +}); +// { +// ratio: 4.5, +// passes: { +// AA_NORMAL: true, +// AA_LARGE: true, +// AAA_NORMAL: false, +// AAA_LARGE: true +// } +// } +``` + +#### `meetsWCAG(comparisonColor: Contrastrast | string, role: "foreground" | "background", level: "AA" | "AAA", textSize?: "normal" | "large"): boolean` + +Check +[WCAG compliance](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) +for specific requirements. + +```typescript +const bgColor = new Contrastrast("#1a73e8"); + +// Check different WCAG levels +const meetsAA = bgColor.meetsWCAG("#ffffff", "background", "AA"); // true +const meetsAAA = bgColor.meetsWCAG("#ffffff", "background", "AAA"); // false +const meetsAALarge = bgColor.meetsWCAG("#ffffff", "background", "AA", "large"); // true +``` + +### Utility Methods + +#### `equals(color: Contrastrast | string): boolean` + +Compare colors for equality. + +```typescript +const color1 = new Contrastrast("#ff0000"); +const color2 = new Contrastrast("rgb(255, 0, 0)"); +console.log(color1.equals(color2)); // true +``` + +## ParseOptions Configuration + +Both the constructor and `parse` method accept optional configuration for error +handling: -hsl(121deg, 90%, 61%) +```typescript +interface ParseOptions { + throwOnError: boolean; // Whether to throw on invalid colors (default: true) + fallbackColor?: string; // Fallback color when throwOnError is false (default: "#000000") +} + +// Safe parsing with fallback +const safeColor = Contrastrast.parse("invalid-color", { + throwOnError: false, + fallbackColor: "#333333", +}); -hsl(298, 90, 61) +// Will throw on invalid color (default behavior) +const strictColor = new Contrastrast("invalid-color"); // throws Error ``` -### Alpha Formats +## Supported Color Formats + +### HEX -Currently `contrastrast` doesn't support alpha formats and will log an error and -return the default value +- `#ff0000` or `ff0000` +- `#f00` or `f00` (short format) -### Unhandled Formats +### RGB + +- `rgb(255, 0, 0)` +- `rgb(100, 200, 230)` + +### HSL -If an unhandled string is passed, by default `contrastrast` will log an error -and return the default value (`"dark"`) +- `hsl(0, 100%, 50%)` +- `hsl(217, 90%, 61%)` -## Options +## Real-World Examples -`textContrastForBGColor` takes an `ContrastrastOptions` object as an optional -second parameter, it currently has the following configuration options: +### React Component with Dynamic Text Color -```ts -type ContrastrastOptions = { - fallbackOption?: "dark" | "light"; // Defaults to "dark" if not specified - throwErrorOnUnhandled?: boolean; // Throws an error instead of returning the `fallbackOption`. Defaults to `false` if not specific +```tsx +import { Contrastrast } from "contrastrast"; + +interface ColorCardProps { + backgroundColor: string; + children: React.ReactNode; +} + +const ColorCard: React.FC = ({ backgroundColor, children }) => { + const bgColor = new Contrastrast(backgroundColor); + const textColor = bgColor.isLight() ? "#000000" : "#ffffff"; + + return ( +
+ {children} +
+ ); }; ``` +### WCAG Compliant Color Picker + +```typescript +import { Contrastrast } from "contrastrast"; + +function validateColorCombination(background: string, foreground: string): { + isValid: boolean; + level: string; + ratio: number; +} { + const bgColor = new Contrastrast(background); + const ratio = bgColor.contrastRatio(foreground); + + const meetsAAA = bgColor.meetsWCAG(foreground, "background", "AAA"); + const meetsAA = bgColor.meetsWCAG(foreground, "background", "AA"); + + return { + isValid: meetsAA, + level: meetsAAA ? "AAA" : meetsAA ? "AA" : "FAIL", + ratio, + }; +} + +const result = validateColorCombination("#1a73e8", "#ffffff"); +console.log(result); // { isValid: true, level: "AA", ratio: 4.5 } +``` + +## Standalone Utility Functions + +In addition to the Contrastrast class methods, contrastrast exports standalone +utility functions for when you need to work with colors without creating class +instances. + +### `textContrast(foreground: Contrastrast | string, background: Contrastrast | string, options?: ContrastOptions): number | ContrastResult` + +Calculate contrast ratio between two colors with optional detailed +[WCAG analysis](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). + +```typescript +import { textContrast } from "contrastrast"; + +// Simple ratio calculation +const ratio = textContrast("#000000", "#ffffff"); // 21 + +// Detailed WCAG analysis +const analysis = textContrast("#1a73e8", "#ffffff", { returnDetails: true }); +// { +// ratio: 4.5, +// passes: { +// AA_NORMAL: true, +// AA_LARGE: true, +// AAA_NORMAL: false, +// AAA_LARGE: true +// } +// } +``` + +### `contrastRatio(color1: Contrastrast | string, color2: Contrastrast | string): number` + +Calculate the +[WCAG 2.1 contrast ratio](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio) +between any two colors. + +```typescript +import { contrastRatio } from "contrastrast"; + +const ratio1 = contrastRatio("#1a73e8", "#ffffff"); // 4.5 +const ratio2 = contrastRatio("rgb(255, 0, 0)", "hsl(0, 0%, 100%)"); // 3.998 + +// Works with mixed formats +const ratio3 = contrastRatio("#000", "rgb(255, 255, 255)"); // 21 +``` + +Both functions accept color strings in any supported format (HEX, RGB, HSL) or +Contrastrast instances. + +## Legacy API Support (v0.3.x) + +For backward compatibility, contrastrast still exports the legacy v0.3.x API, +but these methods are deprecated and will be removed in v2.0. + +### `textContrastForBGColor(bgColorString: string, options?: Partial): "dark" | "light"` + +**⚠️ Deprecated** - Use `new Contrastrast(bgColor).isLight() ? "dark" : "light"` +or the new class methods instead. + +```typescript +import { textContrastForBGColor } from "contrastrast"; + +// Legacy usage (deprecated) +const textColor = textContrastForBGColor("#1a73e8"); // "light" + +// Recommended v1.0+ approach +const bgColor = new Contrastrast("#1a73e8"); +const textColor = bgColor.isLight() ? "dark" : "light"; // "light" +``` + +**Migration Guide:** + +- Replace `textContrastForBGColor(color)` with + `new Contrastrast(color).isLight() ? "dark" : "light"` +- For more sophisticated analysis, use the new WCAG-compliant methods like + `meetsWCAG()` or `textContrast()` +- The legacy `ContrastrastOptions` are replaced by `ParseOptions` for error + handling + ## Contributing -Happy for any and all contributions. Please note the project uses `pnpm` and I -prefer to have git commits formatted with -[`gitmoji-cli`](https://github.com/carloscuesta/gitmoji-cli) +Happy for any and all contributions. This project uses Deno for development with +the following commands: + +- `deno test` - Run tests +- `deno lint` - Lint code +- `deno fmt` - Format code +- `deno task build:npm` - Test building the NPM distribution + +Please note I prefer git commits formatted with +[`gitmoji-cli`](https://github.com/carloscuesta/gitmoji-cli). diff --git a/types/Colors.types.ts b/types/Colors.types.ts index f1edb50..f527d00 100644 --- a/types/Colors.types.ts +++ b/types/Colors.types.ts @@ -1,11 +1,23 @@ +/** + * RGB color values + */ export type RGBValues = { + /** Red component (0-255) */ r: number; + /** Green component (0-255) */ g: number; + /** Blue component (0-255) */ b: number; }; +/** + * HSL color values + */ export type HSLValues = { + /** Hue in degrees (0-360) */ h: number; + /** Saturation percentage (0-100) */ s: number; + /** Lightness percentage (0-100) */ l: number; }; diff --git a/types/ParseOptions.types.ts b/types/ParseOptions.types.ts index ec72e55..2fb6154 100644 --- a/types/ParseOptions.types.ts +++ b/types/ParseOptions.types.ts @@ -1,4 +1,9 @@ +/** + * Configuration options for parsing color strings + */ export type ParseOptions = { + /** Whether to throw an error on invalid color strings (default: true) */ throwOnError: boolean; + /** Fallback color to use when throwOnError is false (default: "#000000") */ fallbackColor?: string; }; diff --git a/types/WCAG.types.ts b/types/WCAG.types.ts index ff934e6..8321f80 100644 --- a/types/WCAG.types.ts +++ b/types/WCAG.types.ts @@ -1,3 +1,9 @@ +/** + * WCAG contrast compliance levels + */ export type WCAGContrastLevel = "AA" | "AAA"; +/** + * WCAG text size categories for contrast requirements + */ export type WCAGTextSize = "normal" | "large"; diff --git a/utils/textContrast.ts b/utils/textContrast.ts index 5bfc860..eece02e 100644 --- a/utils/textContrast.ts +++ b/utils/textContrast.ts @@ -2,17 +2,30 @@ import type { Contrastrast } from "../contrastrast.ts"; import { WCAG_LEVELS } from "../constants.ts"; import { contrastRatio } from "./contrastRatio.ts"; +/** + * Detailed contrast analysis result with WCAG compliance information + */ export type ContrastResult = { + /** Contrast ratio (1:1 to 21:1) */ ratio: number; + /** WCAG compliance test results */ passes: { + /** WCAG AA compliance for normal text (4.5:1 threshold) */ AA_NORMAL: boolean; + /** WCAG AA compliance for large text (3:1 threshold) */ AA_LARGE: boolean; + /** WCAG AAA compliance for normal text (7:1 threshold) */ AAA_NORMAL: boolean; + /** WCAG AAA compliance for large text (4.5:1 threshold) */ AAA_LARGE: boolean; }; }; +/** + * Options for contrast analysis functions + */ export type ContrastOptions = { + /** Return detailed WCAG analysis instead of just the ratio (default: false) */ returnDetails?: boolean; }; From 298a64c0f2af2a196152a4bd72d0e38f254b1caa Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 20:11:25 -0500 Subject: [PATCH 16/22] =?UTF-8?q?=F0=9F=92=9A=20Add=20ci=20quality,=20buil?= =?UTF-8?q?d=20and=20publish=20stuff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-validation.yml | 45 ++++++++++ .github/workflows/publish-jsr.yml | 84 ++++++++++++++++++ .github/workflows/publish-npm.yml | 122 +++++++++++++++++++++++++++ .github/workflows/quality-checks.yml | 65 ++++++++++++++ 4 files changed, 316 insertions(+) create mode 100644 .github/workflows/pr-validation.yml create mode 100644 .github/workflows/publish-jsr.yml create mode 100644 .github/workflows/publish-npm.yml create mode 100644 .github/workflows/quality-checks.yml diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..0be6d09 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,45 @@ +name: PR Validation + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + quality-checks: + uses: ./.github/workflows/quality-checks.yml + + build-validation: + name: Build Validation + runs-on: ubuntu-latest + needs: quality-checks + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Build NPM package + run: deno run -A scripts/build_npm.ts + + - name: Verify NPM package structure + run: | + test -f npm/package.json + test -d npm/esm + test -d npm/script + + - name: Test NPM package can be imported + working-directory: npm + run: | + npm install + node -e "const contrastrast = require('./script/mod.js'); console.log('NPM package import successful')" + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: npm-package + path: npm/ + retention-days: 7 diff --git a/.github/workflows/publish-jsr.yml b/.github/workflows/publish-jsr.yml new file mode 100644 index 0000000..e81c44b --- /dev/null +++ b/.github/workflows/publish-jsr.yml @@ -0,0 +1,84 @@ +name: Publish to JSR + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to publish (e.g., v1.0.0)" + required: true + type: string + +jobs: + validate: + name: Pre-publish Validation + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Publishing version: ${VERSION}" + + - name: Verify version in deno.json matches tag + run: | + DENO_VERSION=$(jq -r '.version' deno.json) + TAG_VERSION="${{ steps.version.outputs.version }}" + TAG_VERSION_CLEAN="${TAG_VERSION#v}" + + if [ "$DENO_VERSION" != "$TAG_VERSION_CLEAN" ]; then + echo "Error: Version mismatch!" + echo "deno.json version: $DENO_VERSION" + echo "Tag version: $TAG_VERSION_CLEAN" + exit 1 + fi + + - name: Run quality checks + run: | + deno fmt --check + deno lint + deno test + + publish: + name: Publish to JSR + runs-on: ubuntu-latest + needs: validate + environment: publishing + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Publish to JSR + env: + JSR_TOKEN: ${{ secrets.JSR_TOKEN }} + run: deno publish + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.validate.outputs.version }} + name: Release ${{ needs.validate.outputs.version }} + generate_release_notes: true + draft: false + prerelease: ${{ contains(needs.validate.outputs.version, '-') }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..334d549 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,122 @@ +name: Publish to NPM + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to publish (e.g., v1.0.0)" + required: true + type: string + +jobs: + validate: + name: Pre-publish Validation + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Publishing version: ${VERSION}" + + - name: Verify version in deno.json matches tag + run: | + DENO_VERSION=$(jq -r '.version' deno.json) + TAG_VERSION="${{ steps.version.outputs.version }}" + TAG_VERSION_CLEAN="${TAG_VERSION#v}" + + if [ "$DENO_VERSION" != "$TAG_VERSION_CLEAN" ]; then + echo "Error: Version mismatch!" + echo "deno.json version: $DENO_VERSION" + echo "Tag version: $TAG_VERSION_CLEAN" + exit 1 + fi + + - name: Run quality checks + run: | + deno fmt --check + deno lint + deno test + + build-and-publish: + name: Build and Publish to NPM + runs-on: ubuntu-latest + needs: validate + environment: publishing + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + registry-url: "https://registry.npmjs.org" + + - name: Build NPM package with dnt + run: deno run -A scripts/build_npm.ts + + - name: Verify NPM package version matches tag + run: | + NPM_VERSION=$(jq -r '.version' npm/package.json) + TAG_VERSION="${{ needs.validate.outputs.version }}" + TAG_VERSION_CLEAN="${TAG_VERSION#v}" + + if [ "$NPM_VERSION" != "$TAG_VERSION_CLEAN" ]; then + echo "Error: NPM package version mismatch!" + echo "npm/package.json version: $NPM_VERSION" + echo "Tag version: $TAG_VERSION_CLEAN" + exit 1 + fi + + - name: Verify package structure + run: | + test -f npm/package.json + test -d npm/esm + test -d npm/script + test -f npm/LICENSE + test -f npm/README.md + + - name: Test NPM package imports + working-directory: npm + run: | + # Test CommonJS import + node -e "const contrastrast = require('./script/mod.js'); console.log('CommonJS import successful:', typeof contrastrast);" + + # Test ESM import + node -e "import('./esm/mod.js').then(m => console.log('ESM import successful:', typeof m.default || typeof m));" + + - name: Publish to NPM + working-directory: npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [[ "${{ needs.validate.outputs.version }}" == *"-"* ]]; then + echo "Publishing non-stable release with beta tag" + npm publish --tag beta + else + echo "Publishing stable release" + npm publish + fi diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml new file mode 100644 index 0000000..4536acf --- /dev/null +++ b/.github/workflows/quality-checks.yml @@ -0,0 +1,65 @@ +name: Quality Checks + +on: + push: + branches: ["*"] + workflow_dispatch: + +jobs: + format: + name: Format Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Check formatting + run: deno fmt --check + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Run linter + run: deno lint + + test: + name: Test on Deno ${{ matrix.deno-version }} + runs-on: ubuntu-latest + strategy: + matrix: + deno-version: ["v1.x", "v2.x"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: ${{ matrix.deno-version }} + + - name: Run tests + run: deno test --coverage=coverage + + - name: Generate coverage report + run: deno coverage coverage --lcov --output=coverage.lcov + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage.lcov + fail_ci_if_error: false From 21e6abbdd6f4954caa8b7370bb0a3e8697844365 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:00:57 -0500 Subject: [PATCH 17/22] =?UTF-8?q?=F0=9F=92=9A=20update=20CI=20to=20run=20o?= =?UTF-8?q?n=20every=20PR,=20fix=20dupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-validation.yml | 4 ---- .github/workflows/quality-checks.yml | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 0be6d09..0568a5b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -6,13 +6,9 @@ on: workflow_dispatch: jobs: - quality-checks: - uses: ./.github/workflows/quality-checks.yml - build-validation: name: Build Validation runs-on: ubuntu-latest - needs: quality-checks steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 4536acf..9b65ca1 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -1,8 +1,7 @@ name: Quality Checks on: - push: - branches: ["*"] + pull_request: workflow_dispatch: jobs: From 4965ee028b76ac1746d7e10ee00840c41616e161 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:03:47 -0500 Subject: [PATCH 18/22] =?UTF-8?q?=F0=9F=92=9A=20Drop=20deno=201.x=20test?= =?UTF-8?q?=20running=20(it=20wasn't=20worth=20it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality-checks.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 9b65ca1..74435cc 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -36,11 +36,8 @@ jobs: run: deno lint test: - name: Test on Deno ${{ matrix.deno-version }} + name: Test w/ Deno runs-on: ubuntu-latest - strategy: - matrix: - deno-version: ["v1.x", "v2.x"] steps: - name: Checkout code @@ -49,7 +46,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: ${{ matrix.deno-version }} + deno-version: v2.x - name: Run tests run: deno test --coverage=coverage From 45909c35ce3584d2bfd761fc9631b551f3586e06 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:07:37 -0500 Subject: [PATCH 19/22] =?UTF-8?q?=F0=9F=92=9A=20Drop=20code=20coverage=20u?= =?UTF-8?q?ploading=20for=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality-checks.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 74435cc..449bb5d 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -49,13 +49,4 @@ jobs: deno-version: v2.x - name: Run tests - run: deno test --coverage=coverage - - - name: Generate coverage report - run: deno coverage coverage --lcov --output=coverage.lcov - - - name: Upload coverage reports - uses: codecov/codecov-action@v3 - with: - file: ./coverage.lcov - fail_ci_if_error: false + run: deno test From efde4024781d3719e4ee136cca1106cb75a3ab71 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:24:49 -0500 Subject: [PATCH 20/22] =?UTF-8?q?=F0=9F=92=9A=20Update=20CI=20build=20uplo?= =?UTF-8?q?ad=20artifact=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-validation.yml | 2 +- .gitignore | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 0568a5b..ffc3cef 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -34,7 +34,7 @@ jobs: node -e "const contrastrast = require('./script/mod.js'); console.log('NPM package import successful')" - name: Upload build artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: npm-package path: npm/ diff --git a/.gitignore b/.gitignore index b0b00b8..233a63f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ npm/ __SPECS__/ demo.ts +.env From 558559726890c91ae9dfb29a08738c44421360a0 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:35:55 -0500 Subject: [PATCH 21/22] =?UTF-8?q?=F0=9F=92=9A=20Update=20npm=20build=20scr?= =?UTF-8?q?ipt,=20add=20new=20attw=20check=20to=20CI=20on=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-validation.yml | 3 +++ scripts/build_npm.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index ffc3cef..dc04877 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -33,6 +33,9 @@ jobs: npm install node -e "const contrastrast = require('./script/mod.js'); console.log('NPM package import successful')" + - name: Validate TypeScript types with attw + run: npx @arethetypeswrong/cli --pack ./npm + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index ad3b194..35edb61 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -40,7 +40,8 @@ await build({ }, }, compilerOptions: { - lib: ["ESNext"], + lib: ["ESNext", "DOM"], + skipLibCheck: true, }, postBuild() { // steps to run after building and before running the tests From a9a5324807e2bc228cbaf9d288183cd0df5ac583 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 23:48:51 -0500 Subject: [PATCH 22/22] =?UTF-8?q?=E2=9C=85=20Add=20tests=20to=20bring=20up?= =?UTF-8?q?=20coverage,=20clean=20up=20deno,=20package.json=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contrastrast_test.ts | 46 ++++++++++++ deno.json | 2 +- helpers/rgbConverters_test.ts | 43 ++++++++++- legacy/textContrastForBGColor_test.ts | 16 ++-- reference-values/reference-colors.ts | 104 ++++++++++++++++++++++++++ scripts/build_npm.ts | 2 +- 6 files changed, 202 insertions(+), 11 deletions(-) diff --git a/contrastrast_test.ts b/contrastrast_test.ts index 7182764..09f8bf6 100644 --- a/contrastrast_test.ts +++ b/contrastrast_test.ts @@ -363,6 +363,52 @@ describe("# Contrastrast", () => { const roundTrip = color.toHex(); expect(roundTrip).toBe(original); }); + + describe("### HSL edge cases", () => { + it("toHsl handles high lightness colors (saturation threshold)", () => { + // Very light color to trigger l > 0.5 branch in HSL conversion + const lightColor = new Contrastrast( + REFERENCE_COLORS.veryLightGray.hex.colorString, + ); + const hsl = lightColor.toHsl(); + expect(typeof hsl.h).toBe("number"); + expect(typeof hsl.s).toBe("number"); + expect(typeof hsl.l).toBe("number"); + }); + + it("toHsl handles green-dominant colors", () => { + // Pure green to trigger case g: branch in HSL conversion + const greenColor = new Contrastrast( + REFERENCE_COLORS.pureGreen.hex.colorString, + ); + const hsl = greenColor.toHsl(); + expect(hsl.h).toBe(120); + expect(hsl.s).toBe(100); + expect(hsl.l).toBe(50); + }); + + it("toHsl handles blue-dominant colors", () => { + // Pure blue to trigger case b: branch in HSL conversion + const blueColor = new Contrastrast( + REFERENCE_COLORS.pureBlue.hex.colorString, + ); + const hsl = blueColor.toHsl(); + expect(hsl.h).toBe(240); + expect(hsl.s).toBe(100); + expect(hsl.l).toBe(50); + }); + + it("toHsl handles yellow color (green < blue condition)", () => { + // Yellow color to trigger g < b condition in red case + const yellowColor = new Contrastrast( + REFERENCE_COLORS.pureYellow.hex.colorString, + ); + const hsl = yellowColor.toHsl(); + expect(hsl.h).toBe(60); + expect(hsl.s).toBe(100); + expect(hsl.l).toBe(50); + }); + }); }); describe("## Luminance and Brightness Calculations", () => { diff --git a/deno.json b/deno.json index 9ae3d7f..2041dbd 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,7 @@ "wcag", "text color", "text contrast", - "constrast", + "contrast", "readability", "legible", "a11y", diff --git a/helpers/rgbConverters_test.ts b/helpers/rgbConverters_test.ts index 3c46b22..f5ea462 100644 --- a/helpers/rgbConverters_test.ts +++ b/helpers/rgbConverters_test.ts @@ -1,7 +1,11 @@ import { type Stub, stub } from "jsr:@std/testing/mock"; import { expect, fn } from "@std/expect"; -import { extractRGBValuesFromHex } from "./rgbConverters.ts"; +import { + extractRGBValuesFromHex, + extractRGBValuesFromHSL, +} from "./rgbConverters.ts"; +import { REFERENCE_COLORS } from "../reference-values/reference-colors.ts"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; describe("# rgbConverters", () => { @@ -25,6 +29,43 @@ describe("# rgbConverters", () => { }); }); + describe("## extractRGBValuesFromHSL", () => { + test("handles HSL edge cases for coverage (t > 1 and 2/3 threshold)", () => { + // This specific HSL value is designed to trigger the missing coverage lines + // in the hue2rgb helper function: t > 1 wrapping and 2/3 threshold + const result = extractRGBValuesFromHSL( + REFERENCE_COLORS.purplishBlue.hsl.h, + REFERENCE_COLORS.purplishBlue.hsl.s, + REFERENCE_COLORS.purplishBlue.hsl.l, + ); + + // Verify we get valid RGB values + expect(result.r).toBeGreaterThanOrEqual(0); + expect(result.r).toBeLessThanOrEqual(255); + expect(result.g).toBeGreaterThanOrEqual(0); + expect(result.g).toBeLessThanOrEqual(255); + expect(result.b).toBeGreaterThanOrEqual(0); + expect(result.b).toBeLessThanOrEqual(255); + }); + + test("handles another HSL edge case for complete coverage", () => { + // Additional HSL value to ensure we hit all mathematical edge cases + const result = extractRGBValuesFromHSL( + REFERENCE_COLORS.magenta.hsl.h, + REFERENCE_COLORS.magenta.hsl.s, + REFERENCE_COLORS.magenta.hsl.l, + ); + + // Verify we get valid RGB values + expect(result.r).toBeGreaterThanOrEqual(0); + expect(result.r).toBeLessThanOrEqual(255); + expect(result.g).toBeGreaterThanOrEqual(0); + expect(result.g).toBeLessThanOrEqual(255); + expect(result.b).toBeGreaterThanOrEqual(0); + expect(result.b).toBeLessThanOrEqual(255); + }); + }); + afterAll(() => { consoleErrorStub?.restore(); }); diff --git a/legacy/textContrastForBGColor_test.ts b/legacy/textContrastForBGColor_test.ts index a89eb42..5c903cd 100644 --- a/legacy/textContrastForBGColor_test.ts +++ b/legacy/textContrastForBGColor_test.ts @@ -69,14 +69,14 @@ describe("# textContrastForBGColor", () => { expect(TEST_RESULT1).toEqual(EXPECTED_FALLBACK1); expect(TEST_RESULT2).toEqual(EXPECTED_FALLBACK2); }); - // test("it throws an error instead of a console log when `throwErrorOnUnhandled` is true", () => { - // const INVALID_COLOR = "~~~"; - // expect(() => { - // textContrastForBGColor(INVALID_COLOR, { - // throwErrorOnUnhandled: true, - // }); - // }).toThrowError(); - // }); + test("it throws an error instead of a console log when `throwErrorOnUnhandled` is true", () => { + const INVALID_COLOR = "~~~"; + expect(() => { + textContrastForBGColor(INVALID_COLOR, { + throwErrorOnUnhandled: true, + }); + }).toThrow(); + }); }); afterAll(() => { diff --git a/reference-values/reference-colors.ts b/reference-values/reference-colors.ts index 6f1e705..90ab190 100644 --- a/reference-values/reference-colors.ts +++ b/reference-values/reference-colors.ts @@ -141,4 +141,108 @@ export const REFERENCE_COLORS: Record = { colorString: "hsl(50, 80%, 40%)", }, }, + // Edge case colors for coverage testing + veryLightGray: { + hex: { + colorString: "#f0f0f0", + }, + rgb: { + r: 240, + g: 240, + b: 240, + colorString: "rgb(240, 240, 240)", + }, + hsl: { + h: "0", + s: "0%", + l: "94%", + colorString: "hsl(0, 0%, 94%)", + }, + }, + pureGreen: { + hex: { + colorString: "#00ff00", + }, + rgb: { + r: 0, + g: 255, + b: 0, + colorString: "rgb(0, 255, 0)", + }, + hsl: { + h: "120", + s: "100%", + l: "50%", + colorString: "hsl(120, 100%, 50%)", + }, + }, + pureBlue: { + hex: { + colorString: "#0000ff", + }, + rgb: { + r: 0, + g: 0, + b: 255, + colorString: "rgb(0, 0, 255)", + }, + hsl: { + h: "240", + s: "100%", + l: "50%", + colorString: "hsl(240, 100%, 50%)", + }, + }, + pureYellow: { + hex: { + colorString: "#ffff00", + }, + rgb: { + r: 255, + g: 255, + b: 0, + colorString: "rgb(255, 255, 0)", + }, + hsl: { + h: "60", + s: "100%", + l: "50%", + colorString: "hsl(60, 100%, 50%)", + }, + }, + // HSL edge case colors for RGB converter coverage testing + purplishBlue: { + hex: { + colorString: "#6600cc", + }, + rgb: { + r: 102, + g: 0, + b: 204, + colorString: "rgb(102, 0, 204)", + }, + hsl: { + h: "270", + s: "100%", + l: "40%", + colorString: "hsl(270, 100%, 40%)", + }, + }, + magenta: { + hex: { + colorString: "#cc3399", + }, + rgb: { + r: 204, + g: 51, + b: 153, + colorString: "rgb(204, 51, 153)", + }, + hsl: { + h: "320", + s: "75%", + l: "50%", + colorString: "hsl(320, 75%, 50%)", + }, + }, } as const; diff --git a/scripts/build_npm.ts b/scripts/build_npm.ts index 35edb61..b033327 100644 --- a/scripts/build_npm.ts +++ b/scripts/build_npm.ts @@ -26,7 +26,7 @@ await build({ "wcag", "text color", "text contrast", - "constrast", + "contrast", "readability", "legible", "a11y",