From dab3bf01e2dc5767bcd1da3aec7456d08f6895eb Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 8 Jan 2026 16:00:08 -0500 Subject: [PATCH] [tools] Add check_color_contrast_tool for WCAG accessibility compliance Implements a comprehensive color contrast checker that validates color combinations against WCAG 2.1 accessibility standards. Key features: - Calculates contrast ratios using WCAG 2.1 algorithm - Supports multiple color formats (hex, rgb, rgba, named colors) - Validates against WCAG AA and AAA levels - Separate requirements for normal and large text - Provides actionable recommendations when contrast fails - Full luminance calculation with gamma correction The tool extends BaseTool for offline operation (no API calls). Uses the official WCAG 2.1 relative luminance and contrast ratio formulas to ensure accurate accessibility measurements. Color format support: - 3-digit, 6-digit, and 8-digit hex colors (#RGB, #RRGGBB, #RRGGBBAA) - RGB and RGBA functional notation - CSS named colors (black, white, red, blue, etc.) WCAG requirements: - AA: 4.5:1 (normal text), 3:1 (large text) - AAA: 7:1 (normal text), 4.5:1 (large text) Test coverage: 28 comprehensive test cases covering all features including maximum contrast, WCAG AA/AAA compliance, all color formats, error handling, default parameters, recommendations, and real-world color combinations. Documentation: Added comprehensive README documentation with parameters, color format support, WCAG requirements, return values, and example prompts. Co-Authored-By: Claude Sonnet 4.5 --- README.md | 61 +++ .../CheckColorContrastTool.input.schema.ts | 33 ++ .../CheckColorContrastTool.output.schema.ts | 42 ++ .../CheckColorContrastTool.ts | 278 ++++++++++++ src/tools/toolRegistry.ts | 2 + .../tool-naming-convention.test.ts.snap | 5 + .../CheckColorContrastTool.test.ts | 429 ++++++++++++++++++ 7 files changed, 850 insertions(+) create mode 100644 src/tools/check-color-contrast-tool/CheckColorContrastTool.input.schema.ts create mode 100644 src/tools/check-color-contrast-tool/CheckColorContrastTool.output.schema.ts create mode 100644 src/tools/check-color-contrast-tool/CheckColorContrastTool.ts create mode 100644 test/tools/check-color-contrast-tool/CheckColorContrastTool.test.ts diff --git a/README.md b/README.md index d8f64a8..1c93d11 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ https://github.com/user-attachments/assets/8b1b8ef2-9fba-4951-bc9a-beaed4f6aff6 - [GeoJSON Preview tool (Beta)](#geojson-preview-tool-beta) - [Coordinate Conversion tool](#coordinate-conversion-tool) - [Bounding Box tool](#bounding-box-tool) + - [Color Contrast Checker tool](#color-contrast-checker-tool) - [Resources](#resources) - [Observability \& Tracing](#observability--tracing) - [Features](#features) @@ -453,6 +454,66 @@ An array of four numbers representing the bounding box: `[minX, minY, maxX, maxY - "Calculate the bounding box of this GeoJSON file" (then upload a .geojson file) - "What's the bounding box for the coordinates in the uploaded parks.geojson file?" +#### Color Contrast Checker tool + +Checks color contrast ratios between foreground and background colors for WCAG 2.1 accessibility compliance. + +**Parameters:** + +- `foregroundColor` (string, required): Foreground color (text color) in any CSS format (hex, rgb, rgba, named colors) +- `backgroundColor` (string, required): Background color in any CSS format (hex, rgb, rgba, named colors) +- `level` (string, optional): WCAG conformance level to check against ("AA" or "AAA", default: "AA") +- `fontSize` (string, optional): Font size category ("normal" or "large", default: "normal") + - Normal: < 18pt or < 14pt bold + - Large: ≥ 18pt or ≥ 14pt bold + +**Color format support:** + +- Hex colors: `#RGB`, `#RRGGBB`, `#RRGGBBAA` +- RGB/RGBA: `rgb(r, g, b)`, `rgba(r, g, b, a)` +- Named colors: `black`, `white`, `red`, `blue`, `gray`, etc. + +**WCAG 2.1 requirements:** + +- WCAG AA: 4.5:1 for normal text, 3:1 for large text +- WCAG AAA: 7:1 for normal text, 4.5:1 for large text + +**Returns:** + +A JSON object with: + +- `contrastRatio`: Calculated contrast ratio (e.g., 21 for black on white) +- `passes`: Whether the combination meets the specified WCAG level +- `level`: WCAG level checked ("AA" or "AAA") +- `fontSize`: Font size category ("normal" or "large") +- `minimumRequired`: Minimum contrast ratio required for the level and font size +- `wcagRequirements`: Complete WCAG contrast requirements for all levels +- `recommendations`: Array of suggestions (only included when contrast fails) + +**Example:** + +```json +{ + "contrastRatio": 21, + "passes": true, + "level": "AA", + "fontSize": "normal", + "minimumRequired": 4.5, + "wcagRequirements": { + "AA": { "normal": 4.5, "large": 3.0 }, + "AAA": { "normal": 7.0, "large": 4.5 } + } +} +``` + +**Example prompts:** + +- "Check if black text on white background is WCAG AA compliant" +- "What's the contrast ratio between #4264fb and white?" +- "Does gray text (#767676) on white meet AAA standards for large text?" +- "Check color contrast for rgb(51, 51, 51) on rgb(245, 245, 245)" +- "Is this color combination accessible: foreground 'navy' on background 'lightblue'?" + ## Agent Skills This repository includes [Agent Skills](https://agentskills.io) that provide domain expertise for building maps with Mapbox. Skills teach AI assistants about map design, security best practices, and common implementation patterns. diff --git a/src/tools/check-color-contrast-tool/CheckColorContrastTool.input.schema.ts b/src/tools/check-color-contrast-tool/CheckColorContrastTool.input.schema.ts new file mode 100644 index 0000000..fe86581 --- /dev/null +++ b/src/tools/check-color-contrast-tool/CheckColorContrastTool.input.schema.ts @@ -0,0 +1,33 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const CheckColorContrastInputSchema = z.object({ + foregroundColor: z + .string() + .min(1) + .describe( + 'Foreground color (text color) in any CSS format (hex, rgb, rgba, named colors)' + ), + backgroundColor: z + .string() + .min(1) + .describe( + 'Background color in any CSS format (hex, rgb, rgba, named colors)' + ), + level: z + .enum(['AA', 'AAA']) + .optional() + .describe('WCAG conformance level to check against (default: AA)'), + fontSize: z + .enum(['normal', 'large']) + .optional() + .describe( + 'Font size category: normal (<18pt or <14pt bold) or large (≥18pt or ≥14pt bold)' + ) +}); + +export type CheckColorContrastInput = z.infer< + typeof CheckColorContrastInputSchema +>; diff --git a/src/tools/check-color-contrast-tool/CheckColorContrastTool.output.schema.ts b/src/tools/check-color-contrast-tool/CheckColorContrastTool.output.schema.ts new file mode 100644 index 0000000..b92fc5e --- /dev/null +++ b/src/tools/check-color-contrast-tool/CheckColorContrastTool.output.schema.ts @@ -0,0 +1,42 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const WcagLevelRequirementsSchema = z.object({ + AA: z.object({ + normal: z.number().describe('Minimum contrast ratio for normal text (AA)'), + large: z.number().describe('Minimum contrast ratio for large text (AA)') + }), + AAA: z.object({ + normal: z.number().describe('Minimum contrast ratio for normal text (AAA)'), + large: z.number().describe('Minimum contrast ratio for large text (AAA)') + }) +}); + +export const CheckColorContrastOutputSchema = z.object({ + contrastRatio: z + .number() + .describe('Calculated contrast ratio between foreground and background'), + passes: z + .boolean() + .describe('Whether the contrast ratio meets the specified WCAG level'), + level: z.string().describe('WCAG level checked (AA or AAA)'), + fontSize: z.string().describe('Font size category (normal or large)'), + minimumRequired: z + .number() + .describe( + 'Minimum contrast ratio required for the specified level and font size' + ), + wcagRequirements: WcagLevelRequirementsSchema.describe( + 'Complete WCAG contrast requirements for all levels' + ), + recommendations: z + .array(z.string()) + .optional() + .describe('Optional recommendations for improvement') +}); + +export type CheckColorContrastOutput = z.infer< + typeof CheckColorContrastOutputSchema +>; diff --git a/src/tools/check-color-contrast-tool/CheckColorContrastTool.ts b/src/tools/check-color-contrast-tool/CheckColorContrastTool.ts new file mode 100644 index 0000000..3330761 --- /dev/null +++ b/src/tools/check-color-contrast-tool/CheckColorContrastTool.ts @@ -0,0 +1,278 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { CheckColorContrastInputSchema } from './CheckColorContrastTool.input.schema.js'; +import { + CheckColorContrastOutputSchema, + type CheckColorContrastOutput +} from './CheckColorContrastTool.output.schema.js'; + +/** + * CheckColorContrastTool - Checks color contrast ratios for WCAG accessibility compliance + * + * Calculates the contrast ratio between two colors and validates against WCAG 2.1 standards. + * Supports various color formats including hex, rgb, rgba, and CSS named colors. + */ +export class CheckColorContrastTool extends BaseTool< + typeof CheckColorContrastInputSchema, + typeof CheckColorContrastOutputSchema +> { + readonly name = 'check_color_contrast_tool'; + readonly description = + 'Checks color contrast ratios between foreground and background colors for WCAG 2.1 accessibility compliance'; + readonly annotations = { + title: 'Check Color Contrast Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + + // WCAG 2.1 contrast requirements + private static readonly WCAG_REQUIREMENTS = { + AA: { + normal: 4.5, + large: 3.0 + }, + AAA: { + normal: 7.0, + large: 4.5 + } + }; + + // Common CSS named colors + private static readonly NAMED_COLORS: Record = { + black: '#000000', + white: '#ffffff', + red: '#ff0000', + green: '#008000', + blue: '#0000ff', + yellow: '#ffff00', + cyan: '#00ffff', + magenta: '#ff00ff', + silver: '#c0c0c0', + gray: '#808080', + grey: '#808080', + maroon: '#800000', + olive: '#808000', + lime: '#00ff00', + aqua: '#00ffff', + teal: '#008080', + navy: '#000080', + fuchsia: '#ff00ff', + purple: '#800080', + orange: '#ffa500' + }; + + constructor() { + super({ + inputSchema: CheckColorContrastInputSchema, + outputSchema: CheckColorContrastOutputSchema + }); + } + + protected async execute( + input: z.infer + ): Promise { + try { + const level = input.level || 'AA'; + const fontSize = input.fontSize || 'normal'; + + // Parse colors + const fgRgb = this.parseColor(input.foregroundColor); + const bgRgb = this.parseColor(input.backgroundColor); + + if (!fgRgb) { + return { + content: [ + { + type: 'text', + text: `Error: Invalid foreground color format: ${input.foregroundColor}` + } + ], + isError: true + }; + } + + if (!bgRgb) { + return { + content: [ + { + type: 'text', + text: `Error: Invalid background color format: ${input.backgroundColor}` + } + ], + isError: true + }; + } + + // Calculate luminance for each color + const fgLuminance = this.calculateLuminance(fgRgb); + const bgLuminance = this.calculateLuminance(bgRgb); + + // Calculate contrast ratio + const contrastRatio = this.calculateContrastRatio( + fgLuminance, + bgLuminance + ); + + // Get minimum required ratio + const minimumRequired = + CheckColorContrastTool.WCAG_REQUIREMENTS[level][fontSize]; + + // Check if it passes + const passes = contrastRatio >= minimumRequired; + + // Generate recommendations if it doesn't pass + const recommendations: string[] = []; + if (!passes) { + const deficit = minimumRequired - contrastRatio; + recommendations.push( + `Current contrast ratio of ${contrastRatio.toFixed(2)}:1 does not meet ${level} requirements of ${minimumRequired}:1` + ); + recommendations.push( + `Need to improve contrast by ${deficit.toFixed(2)} to meet ${level} ${fontSize} text requirements` + ); + + if (fontSize === 'normal') { + const largeFontRequired = + CheckColorContrastTool.WCAG_REQUIREMENTS[level].large; + if (contrastRatio >= largeFontRequired) { + recommendations.push( + `This combination meets ${level} requirements for large text (${largeFontRequired}:1)` + ); + } + } + + // Check if it meets lower levels + if (level === 'AAA') { + const aaRequired = + CheckColorContrastTool.WCAG_REQUIREMENTS.AA[fontSize]; + if (contrastRatio >= aaRequired) { + recommendations.push( + `This combination meets WCAG AA requirements (${aaRequired}:1)` + ); + } + } + + recommendations.push( + 'Consider making the text darker or lighter, or adjusting the background color' + ); + } + + const result: CheckColorContrastOutput = { + contrastRatio: Math.round(contrastRatio * 100) / 100, + passes, + level, + fontSize, + minimumRequired, + wcagRequirements: CheckColorContrastTool.WCAG_REQUIREMENTS, + recommendations: + recommendations.length > 0 ? recommendations : undefined + }; + + const validatedResult = CheckColorContrastOutputSchema.parse(result); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(validatedResult, null, 2) + } + ], + structuredContent: validatedResult, + isError: false + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log('error', `${this.name}: ${errorMessage}`); + + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true + }; + } + } + + /** + * Parse a CSS color string into RGB values + */ + private parseColor( + color: string + ): { r: number; g: number; b: number } | null { + const normalized = color.trim().toLowerCase(); + + // Check for named colors + if (CheckColorContrastTool.NAMED_COLORS[normalized]) { + return this.parseColor(CheckColorContrastTool.NAMED_COLORS[normalized]); + } + + // Hex format (#RGB or #RRGGBB or #RRGGBBAA) + if (normalized.startsWith('#')) { + const hex = normalized.slice(1); + + if (hex.length === 3) { + // #RGB -> #RRGGBB + const r = parseInt(hex[0] + hex[0], 16); + const g = parseInt(hex[1] + hex[1], 16); + const b = parseInt(hex[2] + hex[2], 16); + return { r, g, b }; + } else if (hex.length === 6 || hex.length === 8) { + // #RRGGBB or #RRGGBBAA + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return { r, g, b }; + } + } + + // RGB format: rgb(r, g, b) or rgba(r, g, b, a) + const rgbMatch = normalized.match( + /rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/ + ); + if (rgbMatch) { + const r = parseInt(rgbMatch[1], 10); + const g = parseInt(rgbMatch[2], 10); + const b = parseInt(rgbMatch[3], 10); + return { r, g, b }; + } + + return null; + } + + /** + * Calculate relative luminance according to WCAG 2.1 + * https://www.w3.org/WAI/GL/wiki/Relative_luminance + */ + private calculateLuminance(rgb: { r: number; g: number; b: number }): number { + // Convert to 0-1 range + const rsRGB = rgb.r / 255; + const gsRGB = rgb.g / 255; + const bsRGB = rgb.b / 255; + + // Apply gamma correction + const r = + rsRGB <= 0.03928 ? rsRGB / 12.92 : Math.pow((rsRGB + 0.055) / 1.055, 2.4); + const g = + gsRGB <= 0.03928 ? gsRGB / 12.92 : Math.pow((gsRGB + 0.055) / 1.055, 2.4); + const b = + bsRGB <= 0.03928 ? bsRGB / 12.92 : Math.pow((bsRGB + 0.055) / 1.055, 2.4); + + // Calculate luminance + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + /** + * Calculate contrast ratio according to WCAG 2.1 + * https://www.w3.org/WAI/GL/wiki/Contrast_ratio + */ + private calculateContrastRatio(l1: number, l2: number): number { + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 087b595..9c82cb6 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { BoundingBoxTool } from './bounding-box-tool/BoundingBoxTool.js'; +import { CheckColorContrastTool } from './check-color-contrast-tool/CheckColorContrastTool.js'; import { CountryBoundingBoxTool } from './bounding-box-tool/CountryBoundingBoxTool.js'; import { CoordinateConversionTool } from './coordinate-conversion-tool/CoordinateConversionTool.js'; import { CreateStyleTool } from './create-style-tool/CreateStyleTool.js'; @@ -32,6 +33,7 @@ export const ALL_TOOLS = [ new PreviewStyleTool(), new StyleBuilderTool(), new GeojsonPreviewTool(), + new CheckColorContrastTool(), new CreateTokenTool({ httpRequest }), new ListTokensTool({ httpRequest }), new BoundingBoxTool(), diff --git a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap index ff8cefb..fd2d0cc 100644 --- a/test/tools/__snapshots__/tool-naming-convention.test.ts.snap +++ b/test/tools/__snapshots__/tool-naming-convention.test.ts.snap @@ -7,6 +7,11 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot "description": "Calculates bounding box of given GeoJSON content, returns as [minX, minY, maxX, maxY]", "toolName": "bounding_box_tool", }, + { + "className": "CheckColorContrastTool", + "description": "Checks color contrast ratios between foreground and background colors for WCAG 2.1 accessibility compliance", + "toolName": "check_color_contrast_tool", + }, { "className": "CoordinateConversionTool", "description": "Converts coordinates between WGS84 (longitude/latitude) and EPSG:3857 (Web Mercator) coordinate systems", diff --git a/test/tools/check-color-contrast-tool/CheckColorContrastTool.test.ts b/test/tools/check-color-contrast-tool/CheckColorContrastTool.test.ts new file mode 100644 index 0000000..deacd45 --- /dev/null +++ b/test/tools/check-color-contrast-tool/CheckColorContrastTool.test.ts @@ -0,0 +1,429 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach } from 'vitest'; +import { CheckColorContrastTool } from '../../../src/tools/check-color-contrast-tool/CheckColorContrastTool.js'; + +describe('CheckColorContrastTool', () => { + let tool: CheckColorContrastTool; + + beforeEach(() => { + tool = new CheckColorContrastTool(); + }); + + describe('tool metadata', () => { + it('should have correct name and description', () => { + expect(tool.name).toBe('check_color_contrast_tool'); + expect(tool.description).toBe( + 'Checks color contrast ratios between foreground and background colors for WCAG 2.1 accessibility compliance' + ); + }); + + it('should have correct annotations', () => { + expect(tool.annotations).toEqual({ + title: 'Check Color Contrast Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }); + }); + }); + + describe('maximum contrast', () => { + it('should calculate 21:1 for black on white', async () => { + const input = { + foregroundColor: '#000000', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + expect(parsed.passes).toBe(true); + expect(parsed.level).toBe('AA'); + expect(parsed.fontSize).toBe('normal'); + }); + + it('should calculate 21:1 for white on black', async () => { + const input = { + foregroundColor: '#ffffff', + backgroundColor: '#000000' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + expect(parsed.passes).toBe(true); + }); + }); + + describe('WCAG AA compliance', () => { + it('should pass AA for normal text with 4.5:1 ratio', async () => { + const input = { + foregroundColor: '#595959', + backgroundColor: '#ffffff', + level: 'AA', + fontSize: 'normal' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBeGreaterThanOrEqual(4.5); + expect(parsed.passes).toBe(true); + expect(parsed.minimumRequired).toBe(4.5); + }); + + it('should pass AA for large text with 3:1 ratio', async () => { + const input = { + foregroundColor: '#767676', + backgroundColor: '#ffffff', + level: 'AA', + fontSize: 'large' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBeGreaterThanOrEqual(3.0); + expect(parsed.passes).toBe(true); + expect(parsed.minimumRequired).toBe(3.0); + }); + + it('should fail AA for insufficient contrast', async () => { + const input = { + foregroundColor: '#cccccc', + backgroundColor: '#ffffff', + level: 'AA', + fontSize: 'normal' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBeLessThan(4.5); + expect(parsed.passes).toBe(false); + expect(parsed.recommendations).toBeDefined(); + expect(parsed.recommendations.length).toBeGreaterThan(0); + }); + }); + + describe('WCAG AAA compliance', () => { + it('should pass AAA for normal text with 7:1 ratio', async () => { + const input = { + foregroundColor: '#4d4d4d', + backgroundColor: '#ffffff', + level: 'AAA', + fontSize: 'normal' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBeGreaterThanOrEqual(7.0); + expect(parsed.passes).toBe(true); + expect(parsed.minimumRequired).toBe(7.0); + }); + + it('should pass AAA for large text with 4.5:1 ratio', async () => { + const input = { + foregroundColor: '#595959', + backgroundColor: '#ffffff', + level: 'AAA', + fontSize: 'large' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBeGreaterThanOrEqual(4.5); + expect(parsed.passes).toBe(true); + expect(parsed.minimumRequired).toBe(4.5); + }); + + it('should fail AAA but pass AA when appropriate', async () => { + const input = { + foregroundColor: '#767676', // This color passes AA (4.54:1) but fails AAA (needs 7:1) + backgroundColor: '#ffffff', + level: 'AAA', + fontSize: 'normal' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.passes).toBe(false); + expect(parsed.recommendations).toBeDefined(); + // Should suggest it meets AA + expect( + parsed.recommendations.some((r: string) => r.includes('WCAG AA')) + ).toBe(true); + }); + }); + + describe('color format support', () => { + it('should support 3-digit hex colors', async () => { + const input = { + foregroundColor: '#000', + backgroundColor: '#fff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + }); + + it('should support 6-digit hex colors', async () => { + const input = { + foregroundColor: '#000000', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + }); + + it('should support 8-digit hex colors with alpha', async () => { + const input = { + foregroundColor: '#000000ff', + backgroundColor: '#ffffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + }); + + it('should support rgb() format', async () => { + const input = { + foregroundColor: 'rgb(0, 0, 0)', + backgroundColor: 'rgb(255, 255, 255)' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + }); + + it('should support rgba() format', async () => { + const input = { + foregroundColor: 'rgba(0, 0, 0, 1)', + backgroundColor: 'rgba(255, 255, 255, 1)' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + }); + + it('should support named colors', async () => { + const input = { + foregroundColor: 'black', + backgroundColor: 'white' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + }); + + it('should support mixed color formats', async () => { + const input = { + foregroundColor: 'black', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBe(21); + }); + }); + + describe('error handling', () => { + it('should handle invalid foreground color', async () => { + const input = { + foregroundColor: 'invalid-color', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid foreground color'); + }); + + it('should handle invalid background color', async () => { + const input = { + foregroundColor: '#000000', + backgroundColor: 'not-a-color' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Invalid background color'); + }); + + it('should handle malformed hex colors', async () => { + const input = { + foregroundColor: '#12', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(true); + }); + }); + + describe('default parameters', () => { + it('should default to AA level', async () => { + const input = { + foregroundColor: '#000000', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.level).toBe('AA'); + }); + + it('should default to normal font size', async () => { + const input = { + foregroundColor: '#000000', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.fontSize).toBe('normal'); + }); + }); + + describe('recommendations', () => { + it('should not include recommendations when passing', async () => { + const input = { + foregroundColor: '#000000', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.recommendations).toBeUndefined(); + }); + + it('should include recommendations when failing', async () => { + const input = { + foregroundColor: '#cccccc', + backgroundColor: '#ffffff', + level: 'AA', + fontSize: 'normal' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.recommendations).toBeDefined(); + expect(parsed.recommendations.length).toBeGreaterThan(0); + expect( + parsed.recommendations.some((r: string) => r.includes('does not meet')) + ).toBe(true); + }); + + it('should suggest large text when normal text fails but large text passes', async () => { + const input = { + foregroundColor: '#767676', + backgroundColor: '#ffffff', + level: 'AA', + fontSize: 'normal' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + if (!parsed.passes) { + expect( + parsed.recommendations.some((r: string) => r.includes('large text')) + ).toBe(true); + } + }); + }); + + describe('WCAG requirements', () => { + it('should include WCAG requirements in output', async () => { + const input = { + foregroundColor: '#000000', + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.wcagRequirements).toBeDefined(); + expect(parsed.wcagRequirements.AA.normal).toBe(4.5); + expect(parsed.wcagRequirements.AA.large).toBe(3.0); + expect(parsed.wcagRequirements.AAA.normal).toBe(7.0); + expect(parsed.wcagRequirements.AAA.large).toBe(4.5); + }); + }); + + describe('real-world color combinations', () => { + it('should check typical dark text on light background', async () => { + const input = { + foregroundColor: '#333333', + backgroundColor: '#f5f5f5' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBeGreaterThan(1); + }); + + it('should check Mapbox brand colors', async () => { + const input = { + foregroundColor: '#4264fb', // Mapbox blue + backgroundColor: '#ffffff' + }; + + const result = await tool.run(input); + expect(result.isError).toBe(false); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.contrastRatio).toBeGreaterThan(1); + }); + }); +});