diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..dc04877 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,44 @@ +name: PR Validation + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-validation: + name: Build Validation + 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: 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: Validate TypeScript types with attw + run: npx @arethetypeswrong/cli --pack ./npm + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + 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..449bb5d --- /dev/null +++ b/.github/workflows/quality-checks.yml @@ -0,0 +1,52 @@ +name: Quality Checks + +on: + pull_request: + 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 w/ Deno + 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 tests + run: deno test diff --git a/.gitignore b/.gitignore index b327b04..233a63f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ coverage # Build artifacts npm/ + +# Development and planning +__SPECS__/ +demo.ts + +.env 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/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/constants.ts b/constants.ts index b4e1caa..bd5d99a 100644 --- a/constants.ts +++ b/constants.ts @@ -1,8 +1,69 @@ -import { ContrastrastOptions } from "./types/contrastrastOptionts.types.ts"; +import type { WCAGContrastLevel, WCAGTextSize } from "./types/WCAG.types.ts"; +// Source: https://www.w3.org/TR/AERT/#color-contrast export const CONTRAST_THRESHOLD = 124; -export const DEFAULT_CONTRASTRAST_OPTIONS: ContrastrastOptions = { - fallbackOption: "dark", - throwErrorOnUnhandled: false, -}; +// 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< + WCAGContrastLevel, + 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; diff --git a/contrastrast.ts b/contrastrast.ts new file mode 100644 index 0000000..acb2cdc --- /dev/null +++ b/contrastrast.ts @@ -0,0 +1,507 @@ +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 { + 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"; +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 + * @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 + * @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, 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") + * @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, + b?: number, + ): Contrastrast { + if (typeof rOrRgb === "object") { + return new Contrastrast(`rgb(${rOrRgb.r}, ${rOrRgb.g}, ${rOrRgb.b})`); + } + 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, + l?: number, + ): Contrastrast { + if (typeof hOrHsl === "object") { + return new Contrastrast(`hsl(${hOrHsl.h}, ${hOrHsl.s}%, ${hOrHsl.l}%)`); + } + 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 + * @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, + parseOpts?: Partial, + ): Contrastrast => new Contrastrast(colorString, parseOpts); + + // 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); + 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; + }; + + /** + * 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; + 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), + }; + }; + + /** + * 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}%)`; + }; + + /** + * 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; + 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 + ); + }; + + /** + * 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 + + this.rgb.b * BRIGHTNESS_COEFFICIENTS.BLUE) / + 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); + + /** + * 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 { + if (role === "background") { + // Current color is background, comparisonColor is foreground (text color) + return textContrast(comparisonColor, this, options); + } else { + // Current color is foreground (text color), comparisonColor is background + return textContrast(this, comparisonColor, options); + } + } + + /** + * 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", + targetWcagLevel: WCAGContrastLevel, + textSize: WCAGTextSize = "normal", + ): boolean => { + const ratio = this.textContrast(comparisonColor, role); + const required = WCAG_LEVELS[targetWcagLevel][textSize]; + return ratio >= required; + }; + + /** + * 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 + : 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/contrastrast_test.ts b/contrastrast_test.ts new file mode 100644 index 0000000..09f8bf6 --- /dev/null +++ b/contrastrast_test.ts @@ -0,0 +1,883 @@ +import { expect } from "@std/expect"; +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 "./reference-values/reference-colors.ts"; +import { WCAG_CONTRAST_REFERENCE } from "./reference-values/wcag-reference-colors.ts"; + +describe("# Contrastrast", () => { + 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("fromHex factory method preserves HEX values", () => { + const originalHex = REFERENCE_COLORS.midnightBlue.hex.colorString; + const color = Contrastrast.fromHex(originalHex); + expect(color.toHex()).toBe(originalHex); + }); + + it("fromHex handles hex without hash", () => { + const color = Contrastrast.fromHex("ff0000"); + expect(color.toHex()).toBe("#ff0000"); + }); + + 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); + }); + }); + }); + + 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 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 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("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, + }); + }); + }); + }); + + 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), + }); + }); + }); + }); + + 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); + }); + }); + + 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", () => { + const redColor = new Contrastrast(REFERENCE_COLORS.red.hex.colorString); + + it("toHex returns hex string with hash by default", () => { + expect(redColor.toHex()).toBe(REFERENCE_COLORS.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 } = REFERENCE_COLORS.red.rgb; + expect(redColor.toRgb()).toEqual({ r, g, b }); + }); + + it("toRgbString returns RGB string", () => { + expect(redColor.toRgbString()).toBe(REFERENCE_COLORS.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 = REFERENCE_COLORS.goldenrod.hex.colorString; + const color = new Contrastrast(original); + 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", () => { + it("luminance calculates WCAG 2.1 relative luminance", () => { + 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(REFERENCE_COLORS.black.hex.colorString); + const white = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); + + expect(black.brightness()).toBe(0); + expect(white.brightness()).toBe(255); + }); + + it("midnight blue has expected luminance", () => { + const midnightBlue = new Contrastrast( + REFERENCE_COLORS.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(REFERENCE_COLORS.white.hex.colorString); + const lightGray = new Contrastrast( + REFERENCE_COLORS.lightGray.hex.colorString, + ); + + expect(white.isLight()).toBe(true); + expect(lightGray.isLight()).toBe(true); + }); + + it("isLight returns false for dark colors", () => { + const black = new Contrastrast(REFERENCE_COLORS.black.hex.colorString); + const darkBlue = new Contrastrast( + REFERENCE_COLORS.midnightBlue.hex.colorString, + ); + + expect(black.isLight()).toBe(false); + expect(darkBlue.isLight()).toBe(false); + }); + + it("isDark is opposite of isLight", () => { + 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()); + }); + + 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(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(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( + REFERENCE_COLORS.midnightBlue.hex.colorString, + ); + const color2 = new Contrastrast(REFERENCE_COLORS.white.hex.colorString); + + expect(color1.contrastRatio(color2)).toBe(color2.contrastRatio(color1)); + }); + }); + + describe("## textContrast Instance Method", () => { + const midnightBlue = new Contrastrast( + REFERENCE_COLORS.midnightBlue.hex.colorString, + ); + + it("returns numeric ratio by default", () => { + const ratio = midnightBlue.textContrast( + REFERENCE_COLORS.white.hex.colorString, + ); + expect(typeof ratio).toBe("number"); + expect(ratio).toBeCloseTo(14.85, 1); + }); + + it("role parameter defaults to 'background'", () => { + const ratioDefault = midnightBlue.textContrast( + REFERENCE_COLORS.white.hex.colorString, + ); + const ratioExplicit = midnightBlue.textContrast( + REFERENCE_COLORS.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( + REFERENCE_COLORS.white.hex.colorString, + "foreground", + ); + expect(ratio).toBeCloseTo(14.85, 1); + }); + + it("accepts Contrastrast instance as input", () => { + 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( + REFERENCE_COLORS.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 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, + ); + }); + }); + + describe("## WCAG Compliance Helper", () => { + // 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 testData = WCAG_CONTRAST_REFERENCE.aaaNormalBorderline; + const color = new Contrastrast(testData.foreground); + const resultWithDefault = color.meetsWCAG( + testData.background, + "foreground", + "AAA", + ); + const resultExplicit = color.meetsWCAG( + testData.background, + "foreground", + "AAA", + "normal", + ); + expect(resultWithDefault).toBe(resultExplicit); + expect(resultWithDefault).toBe(testData.expectedWCAGResults.AAA_NORMAL); + }); + }); + + describe("## Color Equality", () => { + it("equals returns true for identical colors", () => { + 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(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(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(REFERENCE_COLORS.red.hex.colorString); + const midnightBlue = new Contrastrast( + REFERENCE_COLORS.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( + REFERENCE_COLORS.midnightBlue.hex.colorString, + ); + + // Check if it works well with white text + const whiteTextRatio = brandColor.textContrast( + REFERENCE_COLORS.white.hex.colorString, + "background", + ); + expect(whiteTextRatio).toBeGreaterThanOrEqual(4.5); + + // Verify WCAG compliance + expect( + brandColor.meetsWCAG( + REFERENCE_COLORS.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 = REFERENCE_COLORS.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( + REFERENCE_COLORS.midnightBlue.hex.colorString, + ); + const originalRgb = original.toRgb(); + + // Calling methods should not modify the original + original.toHex(); + original.toHsl(); + 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); + }); + }); + + 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); + }); + }); + }); +}); diff --git a/deno.json b/deno.json index 35d7776..2041dbd 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,7 @@ "wcag", "text color", "text contrast", - "constrast", + "contrast", "readability", "legible", "a11y", @@ -22,14 +22,29 @@ "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" }, + "exclude": [ + "npm/", + "__SPECS__/", + "scripts/", + "node_modules/" + ], + "test": { + "exclude": [ + "npm/", + "__SPECS__/", + "scripts/", + "node_modules/", + "demo.ts" + ] + }, "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 b665a3f..07a0cb3 100644 --- a/deno.lock +++ b/deno.lock @@ -1,9 +1,10 @@ { - "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:@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", @@ -43,6 +44,7 @@ "@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", @@ -60,6 +62,9 @@ "jsr:@ts-morph/bootstrap" ] }, + "@deno/graph@0.73.1": { + "integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41" + }, "@std/assert@0.223.0": { "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" }, @@ -259,7 +264,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 +301,8 @@ "pidtree", "string-argv", "yaml" - ] + ], + "bin": true }, "listr2@8.2.5": { "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", @@ -365,7 +372,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 +441,8 @@ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ "isexe" - ] + ], + "bin": true }, "wrap-ansi@9.0.0": { "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", @@ -444,7 +453,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..3e8d8fc 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/Colors.types.ts"; import { extractRGBValuesFromHex, @@ -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) { @@ -31,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/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.test.ts b/helpers/rgbConverters.test.ts deleted file mode 100644 index 087a3ee..0000000 --- a/helpers/rgbConverters.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Stub, stub } from "jsr:@std/testing/mock"; -import { expect, fn } from "@std/expect"; - -import { extractRGBValuesFromHex } from "./rgbConverters.ts"; -import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; - -describe("# rgbConverters", () => { - const consoleErrorSpy = fn(); - let consoleErrorStub: Stub | undefined; - - beforeAll(() => { - // deno-lint-ignore no-explicit-any - consoleErrorStub = stub(console, "error", consoleErrorSpy as any); - }); - - describe("## extractRGBValuesFromHex", () => { - test("it should return the same value for a short (3-len) hex code as the equivalent long (6-len) hex code", () => { - const SHORT_HEX = "ad0"; - const LONG_HEX = "aadd00"; - - const RESULT1 = extractRGBValuesFromHex(SHORT_HEX); - const RESULT2 = extractRGBValuesFromHex(LONG_HEX); - - expect(RESULT1).toEqual(RESULT2); - }); - }); - - afterAll(() => { - consoleErrorStub?.restore(); - }); -}); diff --git a/helpers/rgbConverters.ts b/helpers/rgbConverters.ts index 8a76cd3..c8140d2 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/Colors.types.ts"; /** * Converts a HEX color value to RGB @@ -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), }; }; diff --git a/helpers/rgbConverters_test.ts b/helpers/rgbConverters_test.ts new file mode 100644 index 0000000..f5ea462 --- /dev/null +++ b/helpers/rgbConverters_test.ts @@ -0,0 +1,72 @@ +import { type Stub, stub } from "jsr:@std/testing/mock"; +import { expect, fn } from "@std/expect"; + +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", () => { + const consoleErrorSpy = fn(); + let consoleErrorStub: Stub | undefined; + + beforeAll(() => { + // deno-lint-ignore no-explicit-any + consoleErrorStub = stub(console, "error", consoleErrorSpy as any); + }); + + describe("## extractRGBValuesFromHex", () => { + test("it should return the same value for a short (3-len) hex code as the equivalent long (6-len) hex code", () => { + const SHORT_HEX = "ad0"; + const LONG_HEX = "aadd00"; + + const RESULT1 = extractRGBValuesFromHex(SHORT_HEX); + const RESULT2 = extractRGBValuesFromHex(LONG_HEX); + + expect(RESULT1).toEqual(RESULT2); + }); + }); + + 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/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.ts b/legacy/textContrastForBGColor.ts similarity index 81% rename from modules/textContrastForBGColor.ts rename to legacy/textContrastForBGColor.ts index 565a321..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 { 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 { 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/modules/textContrastForBGColor.test.ts b/legacy/textContrastForBGColor_test.ts similarity index 84% rename from modules/textContrastForBGColor.test.ts rename to legacy/textContrastForBGColor_test.ts index 5e88f1b..5c903cd 100644 --- a/modules/textContrastForBGColor.test.ts +++ b/legacy/textContrastForBGColor_test.ts @@ -1,11 +1,11 @@ -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 { textContrastForBGColor } from "../main.ts"; +import { textContrastForBGColor } from "./textContrastForBGColor.ts"; +import type { ContrastrastOptions } from "./contrastrastOptions.types.ts"; describe("# textContrastForBGColor", () => { const consoleErrorSpy = fn(); @@ -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/main.ts b/main.ts deleted file mode 100644 index cd119c0..0000000 --- a/main.ts +++ /dev/null @@ -1 +0,0 @@ -export { textContrastForBGColor } from "./modules/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/reference-values/reference-colors.ts b/reference-values/reference-colors.ts new file mode 100644 index 0000000..90ab190 --- /dev/null +++ b/reference-values/reference-colors.ts @@ -0,0 +1,248 @@ +/** + * Reference color values for consistent testing + * across the contrastrast library test suite + */ + +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%)", + }, + }, + // 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/reference-values/wcag-reference-colors.ts b/reference-values/wcag-reference-colors.ts new file mode 100644 index 0000000..dd2154f --- /dev/null +++ b/reference-values/wcag-reference-colors.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/scripts/build_npm.ts b/scripts/build_npm.ts index a260046..b033327 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: { @@ -26,7 +26,7 @@ await build({ "wcag", "text color", "text contrast", - "constrast", + "contrast", "readability", "legible", "a11y", @@ -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 diff --git a/types/Colors.types.ts b/types/Colors.types.ts new file mode 100644 index 0000000..f527d00 --- /dev/null +++ b/types/Colors.types.ts @@ -0,0 +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 new file mode 100644 index 0000000..2fb6154 --- /dev/null +++ b/types/ParseOptions.types.ts @@ -0,0 +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/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..8321f80 --- /dev/null +++ b/types/WCAG.types.ts @@ -0,0 +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/contrastRatio.ts b/utils/contrastRatio.ts new file mode 100644 index 0000000..347e414 --- /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/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 new file mode 100644 index 0000000..eece02e --- /dev/null +++ b/utils/textContrast.ts @@ -0,0 +1,115 @@ +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; +}; + +/** + * 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 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 { + const { returnDetails = false } = options; + + const ratio = contrastRatio(foreground, background); + + if (!returnDetails) { + return ratio; + } + + 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, + }; +} diff --git a/utils/textContrast_test.ts b/utils/textContrast_test.ts new file mode 100644 index 0000000..5763891 --- /dev/null +++ b/utils/textContrast_test.ts @@ -0,0 +1,192 @@ +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"; + +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 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(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"); + }); + }); + + 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"); + }); + }); +});