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 @@
[](https://jsr.io/@amuench/contrastrast)
[](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");
+ });
+ });
+});