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); + }); + }); +});