feat(plugins): implement Sprint 4 commands (#241-#258)

Sprint 4 - Plugin Commands implementation adding 18 new user-facing
commands across 8 plugins as part of V5.2.0 Plugin Enhancements.

**projman:**
- #241: /sprint-diagram - Mermaid visualization of sprint issues

**pr-review:**
- #242: Confidence threshold config (PR_REVIEW_CONFIDENCE_THRESHOLD)
- #243: /pr-diff - Formatted diff with inline review comments

**data-platform:**
- #244: /data-quality - DataFrame quality checks (nulls, duplicates, outliers)
- #245: /lineage-viz - dbt lineage as Mermaid diagrams
- #246: /dbt-test - Formatted dbt test runner

**viz-platform:**
- #247: /chart-export - Export charts to PNG/SVG/PDF via kaleido
- #248: /accessibility-check - Color blind validation (WCAG contrast)
- #249: /breakpoints - Responsive layout configuration

**contract-validator:**
- #250: /dependency-graph - Plugin dependency visualization

**doc-guardian:**
- #251: /changelog-gen - Generate changelog from conventional commits
- #252: /doc-coverage - Documentation coverage metrics
- #253: /stale-docs - Flag outdated documentation

**claude-config-maintainer:**
- #254: /config-diff - Track CLAUDE.md changes over time
- #255: /config-lint - 31 lint rules for CLAUDE.md best practices

**cmdb-assistant:**
- #256: /cmdb-topology - Infrastructure topology diagrams
- #257: /change-audit - NetBox audit trail queries
- #258: /ip-conflicts - Detect IP conflicts and overlaps

Closes #241, #242, #243, #244, #245, #246, #247, #248, #249,
#250, #251, #252, #253, #254, #255, #256, #257, #258

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 12:02:26 -05:00
parent 8a957b1b69
commit 9698e8724d
36 changed files with 4295 additions and 22 deletions

View File

@@ -0,0 +1,479 @@
"""
Accessibility validation tools for color blindness and WCAG compliance.
Provides tools for validating color palettes against color blindness
simulations and WCAG contrast requirements.
"""
import logging
import math
from typing import Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
# Color-blind safe palettes
SAFE_PALETTES = {
"categorical": {
"name": "Paul Tol's Qualitative",
"colors": ["#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377", "#BBBBBB"],
"description": "Distinguishable for all types of color blindness"
},
"ibm": {
"name": "IBM Design",
"colors": ["#648FFF", "#785EF0", "#DC267F", "#FE6100", "#FFB000"],
"description": "IBM's accessible color palette"
},
"okabe_ito": {
"name": "Okabe-Ito",
"colors": ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#000000"],
"description": "Optimized for all color vision deficiencies"
},
"tableau_colorblind": {
"name": "Tableau Colorblind 10",
"colors": ["#006BA4", "#FF800E", "#ABABAB", "#595959", "#5F9ED1",
"#C85200", "#898989", "#A2C8EC", "#FFBC79", "#CFCFCF"],
"description": "Industry-standard accessible palette"
}
}
# Simulation matrices for color blindness (LMS color space transformation)
# These approximate how colors appear to people with different types of color blindness
SIMULATION_MATRICES = {
"deuteranopia": {
# Green-blind (most common)
"severity": "common",
"population": "6% males, 0.4% females",
"description": "Difficulty distinguishing red from green (green-blind)",
"matrix": [
[0.625, 0.375, 0.0],
[0.700, 0.300, 0.0],
[0.0, 0.300, 0.700]
]
},
"protanopia": {
# Red-blind
"severity": "common",
"population": "2.5% males, 0.05% females",
"description": "Difficulty distinguishing red from green (red-blind)",
"matrix": [
[0.567, 0.433, 0.0],
[0.558, 0.442, 0.0],
[0.0, 0.242, 0.758]
]
},
"tritanopia": {
# Blue-blind (rare)
"severity": "rare",
"population": "0.01% total",
"description": "Difficulty distinguishing blue from yellow",
"matrix": [
[0.950, 0.050, 0.0],
[0.0, 0.433, 0.567],
[0.0, 0.475, 0.525]
]
}
}
class AccessibilityTools:
"""
Color accessibility validation tools.
Validates colors for WCAG compliance and color blindness accessibility.
"""
def __init__(self, theme_store=None):
"""
Initialize accessibility tools.
Args:
theme_store: Optional ThemeStore for theme color extraction
"""
self.theme_store = theme_store
def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
"""Convert hex color to RGB tuple."""
hex_color = hex_color.lstrip('#')
if len(hex_color) == 3:
hex_color = ''.join([c * 2 for c in hex_color])
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def _rgb_to_hex(self, rgb: Tuple[int, int, int]) -> str:
"""Convert RGB tuple to hex color."""
return '#{:02x}{:02x}{:02x}'.format(
max(0, min(255, int(rgb[0]))),
max(0, min(255, int(rgb[1]))),
max(0, min(255, int(rgb[2])))
)
def _get_relative_luminance(self, rgb: Tuple[int, int, int]) -> float:
"""
Calculate relative luminance per WCAG 2.1.
https://www.w3.org/WAI/GL/wiki/Relative_luminance
"""
def channel_luminance(value: int) -> float:
v = value / 255
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return (
0.2126 * channel_luminance(r) +
0.7152 * channel_luminance(g) +
0.0722 * channel_luminance(b)
)
def _get_contrast_ratio(self, color1: str, color2: str) -> float:
"""
Calculate contrast ratio between two colors per WCAG 2.1.
Returns ratio between 1:1 and 21:1.
"""
rgb1 = self._hex_to_rgb(color1)
rgb2 = self._hex_to_rgb(color2)
l1 = self._get_relative_luminance(rgb1)
l2 = self._get_relative_luminance(rgb2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def _simulate_color_blindness(
self,
hex_color: str,
deficiency_type: str
) -> str:
"""
Simulate how a color appears with a specific color blindness type.
Uses linear RGB transformation approximation.
"""
if deficiency_type not in SIMULATION_MATRICES:
return hex_color
rgb = self._hex_to_rgb(hex_color)
matrix = SIMULATION_MATRICES[deficiency_type]["matrix"]
# Apply transformation matrix
r = rgb[0] * matrix[0][0] + rgb[1] * matrix[0][1] + rgb[2] * matrix[0][2]
g = rgb[0] * matrix[1][0] + rgb[1] * matrix[1][1] + rgb[2] * matrix[1][2]
b = rgb[0] * matrix[2][0] + rgb[1] * matrix[2][1] + rgb[2] * matrix[2][2]
return self._rgb_to_hex((r, g, b))
def _get_color_distance(self, color1: str, color2: str) -> float:
"""
Calculate perceptual color distance (CIE76 approximation).
Returns a value where < 20 means colors may be hard to distinguish.
"""
rgb1 = self._hex_to_rgb(color1)
rgb2 = self._hex_to_rgb(color2)
# Simple Euclidean distance in RGB space (approximation)
# For production, should use CIEDE2000
return math.sqrt(
(rgb1[0] - rgb2[0]) ** 2 +
(rgb1[1] - rgb2[1]) ** 2 +
(rgb1[2] - rgb2[2]) ** 2
)
async def accessibility_validate_colors(
self,
colors: List[str],
check_types: Optional[List[str]] = None,
min_contrast_ratio: float = 4.5
) -> Dict[str, Any]:
"""
Validate a list of colors for accessibility.
Args:
colors: List of hex colors to validate
check_types: Color blindness types to check (default: all)
min_contrast_ratio: Minimum WCAG contrast ratio (default: 4.5 for AA)
Returns:
Dict with:
- issues: List of accessibility issues found
- simulations: How colors appear under each deficiency
- recommendations: Suggestions for improvement
- safe_palettes: Color-blind safe palette suggestions
"""
check_types = check_types or list(SIMULATION_MATRICES.keys())
issues = []
simulations = {}
# Normalize colors
normalized_colors = [c.upper() if c.startswith('#') else f'#{c.upper()}' for c in colors]
# Simulate each color blindness type
for deficiency in check_types:
if deficiency not in SIMULATION_MATRICES:
continue
simulated = [self._simulate_color_blindness(c, deficiency) for c in normalized_colors]
simulations[deficiency] = {
"original": normalized_colors,
"simulated": simulated,
"info": SIMULATION_MATRICES[deficiency]
}
# Check if any color pairs become indistinguishable
for i in range(len(normalized_colors)):
for j in range(i + 1, len(normalized_colors)):
distance = self._get_color_distance(simulated[i], simulated[j])
if distance < 30: # Threshold for distinguishability
issues.append({
"type": "distinguishability",
"severity": "warning" if distance > 15 else "error",
"colors": [normalized_colors[i], normalized_colors[j]],
"affected_by": [deficiency],
"simulated_colors": [simulated[i], simulated[j]],
"distance": round(distance, 1),
"message": f"Colors may be hard to distinguish for {deficiency} ({SIMULATION_MATRICES[deficiency]['description']})"
})
# Check contrast ratios against white and black backgrounds
for color in normalized_colors:
white_contrast = self._get_contrast_ratio(color, "#FFFFFF")
black_contrast = self._get_contrast_ratio(color, "#000000")
if white_contrast < min_contrast_ratio and black_contrast < min_contrast_ratio:
issues.append({
"type": "contrast_ratio",
"severity": "error",
"colors": [color],
"white_contrast": round(white_contrast, 2),
"black_contrast": round(black_contrast, 2),
"required": min_contrast_ratio,
"message": f"Insufficient contrast against both white ({white_contrast:.1f}:1) and black ({black_contrast:.1f}:1) backgrounds"
})
# Generate recommendations
recommendations = self._generate_recommendations(issues)
# Calculate overall score
error_count = sum(1 for i in issues if i["severity"] == "error")
warning_count = sum(1 for i in issues if i["severity"] == "warning")
if error_count == 0 and warning_count == 0:
score = "A"
elif error_count == 0 and warning_count <= 2:
score = "B"
elif error_count <= 2:
score = "C"
else:
score = "D"
return {
"colors_checked": normalized_colors,
"overall_score": score,
"issue_count": len(issues),
"issues": issues,
"simulations": simulations,
"recommendations": recommendations,
"safe_palettes": SAFE_PALETTES
}
async def accessibility_validate_theme(
self,
theme_name: str
) -> Dict[str, Any]:
"""
Validate a theme's colors for accessibility.
Args:
theme_name: Theme name to validate
Returns:
Dict with accessibility validation results
"""
if not self.theme_store:
return {
"error": "Theme store not configured",
"theme_name": theme_name
}
theme = self.theme_store.get_theme(theme_name)
if not theme:
available = self.theme_store.list_themes()
return {
"error": f"Theme '{theme_name}' not found. Available: {available}",
"theme_name": theme_name
}
# Extract colors from theme
colors = []
tokens = theme.get("tokens", {})
color_tokens = tokens.get("colors", {})
def extract_colors(obj, prefix=""):
"""Recursively extract color values."""
if isinstance(obj, str) and (obj.startswith('#') or len(obj) == 6):
colors.append(obj if obj.startswith('#') else f'#{obj}')
elif isinstance(obj, dict):
for key, value in obj.items():
extract_colors(value, f"{prefix}.{key}")
elif isinstance(obj, list):
for item in obj:
extract_colors(item, prefix)
extract_colors(color_tokens)
# Validate extracted colors
result = await self.accessibility_validate_colors(colors)
result["theme_name"] = theme_name
# Add theme-specific checks
primary = color_tokens.get("primary")
background = color_tokens.get("background", {})
text = color_tokens.get("text", {})
if primary and background:
bg_color = background.get("base") if isinstance(background, dict) else background
if bg_color:
contrast = self._get_contrast_ratio(primary, bg_color)
if contrast < 4.5:
result["issues"].append({
"type": "primary_contrast",
"severity": "error",
"colors": [primary, bg_color],
"ratio": round(contrast, 2),
"required": 4.5,
"message": f"Primary color has insufficient contrast ({contrast:.1f}:1) against background"
})
return result
async def accessibility_suggest_alternative(
self,
color: str,
deficiency_type: str
) -> Dict[str, Any]:
"""
Suggest accessible alternative colors.
Args:
color: Original hex color
deficiency_type: Type of color blindness to optimize for
Returns:
Dict with alternative color suggestions
"""
rgb = self._hex_to_rgb(color)
suggestions = []
# Suggest shifting hue while maintaining saturation and brightness
# For red-green deficiency, shift toward blue or yellow
if deficiency_type in ["deuteranopia", "protanopia"]:
# Shift toward blue
blue_shift = self._rgb_to_hex((
max(0, rgb[0] - 50),
max(0, rgb[1] - 30),
min(255, rgb[2] + 80)
))
suggestions.append({
"color": blue_shift,
"description": "Blue-shifted alternative",
"preserves": "approximate brightness"
})
# Shift toward yellow/orange
yellow_shift = self._rgb_to_hex((
min(255, rgb[0] + 50),
min(255, rgb[1] + 30),
max(0, rgb[2] - 80)
))
suggestions.append({
"color": yellow_shift,
"description": "Yellow-shifted alternative",
"preserves": "approximate brightness"
})
elif deficiency_type == "tritanopia":
# For blue-yellow deficiency, shift toward red or green
red_shift = self._rgb_to_hex((
min(255, rgb[0] + 60),
max(0, rgb[1] - 20),
max(0, rgb[2] - 40)
))
suggestions.append({
"color": red_shift,
"description": "Red-shifted alternative",
"preserves": "approximate brightness"
})
# Add safe palette suggestions
for palette_name, palette in SAFE_PALETTES.items():
# Find closest color in safe palette
min_distance = float('inf')
closest = None
for safe_color in palette["colors"]:
distance = self._get_color_distance(color, safe_color)
if distance < min_distance:
min_distance = distance
closest = safe_color
if closest:
suggestions.append({
"color": closest,
"description": f"From {palette['name']} palette",
"palette": palette_name
})
return {
"original_color": color,
"deficiency_type": deficiency_type,
"suggestions": suggestions[:5] # Limit to 5 suggestions
}
def _generate_recommendations(self, issues: List[Dict[str, Any]]) -> List[str]:
"""Generate actionable recommendations based on issues."""
recommendations = []
# Check for distinguishability issues
distinguishability_issues = [i for i in issues if i["type"] == "distinguishability"]
if distinguishability_issues:
affected_types = set()
for issue in distinguishability_issues:
affected_types.update(issue.get("affected_by", []))
if "deuteranopia" in affected_types or "protanopia" in affected_types:
recommendations.append(
"Avoid using red and green as the only differentiators - "
"add patterns, shapes, or labels"
)
recommendations.append(
"Consider using a color-blind safe palette like Okabe-Ito or IBM Design"
)
# Check for contrast issues
contrast_issues = [i for i in issues if i["type"] in ["contrast_ratio", "primary_contrast"]]
if contrast_issues:
recommendations.append(
"Increase contrast by darkening colors for light backgrounds "
"or lightening for dark backgrounds"
)
recommendations.append(
"Use WCAG contrast checker tools to verify text readability"
)
# General recommendations
if len(issues) > 0:
recommendations.append(
"Add secondary visual cues (icons, patterns, labels) "
"to not rely solely on color"
)
if not recommendations:
recommendations.append(
"Color palette appears accessible! Consider adding patterns "
"for additional distinguishability"
)
return recommendations

View File

@@ -3,11 +3,21 @@ Chart creation tools using Plotly.
Provides tools for creating data visualizations with automatic theme integration.
"""
import base64
import logging
import os
from typing import Dict, List, Optional, Any, Union
logger = logging.getLogger(__name__)
# Check for kaleido availability
KALEIDO_AVAILABLE = False
try:
import kaleido
KALEIDO_AVAILABLE = True
except ImportError:
logger.debug("kaleido not installed - chart export will be unavailable")
# Default color palette based on Mantine theme
DEFAULT_COLORS = [
@@ -395,3 +405,129 @@ class ChartTools:
"figure": figure,
"interactions_added": []
}
async def chart_export(
self,
figure: Dict[str, Any],
format: str = "png",
width: Optional[int] = None,
height: Optional[int] = None,
scale: float = 2.0,
output_path: Optional[str] = None
) -> Dict[str, Any]:
"""
Export a Plotly chart to a static image format.
Args:
figure: Plotly figure JSON to export
format: Output format - png, svg, or pdf
width: Image width in pixels (default: from figure or 1200)
height: Image height in pixels (default: from figure or 800)
scale: Resolution scale factor (default: 2 for retina)
output_path: Optional file path to save the image
Returns:
Dict with:
- image_data: Base64-encoded image (if no output_path)
- file_path: Path to saved file (if output_path provided)
- format: Export format used
- dimensions: {width, height, scale}
- error: Error message if export failed
"""
# Validate format
valid_formats = ['png', 'svg', 'pdf']
format = format.lower()
if format not in valid_formats:
return {
"error": f"Invalid format '{format}'. Must be one of: {valid_formats}",
"format": format,
"image_data": None
}
# Check kaleido availability
if not KALEIDO_AVAILABLE:
return {
"error": "kaleido package not installed. Install with: pip install kaleido",
"format": format,
"image_data": None,
"install_hint": "pip install kaleido"
}
# Validate figure
if not figure or 'data' not in figure:
return {
"error": "Invalid figure: must contain 'data' key",
"format": format,
"image_data": None
}
try:
import plotly.graph_objects as go
import plotly.io as pio
# Create Plotly figure object
fig = go.Figure(figure)
# Determine dimensions
layout = figure.get('layout', {})
export_width = width or layout.get('width') or 1200
export_height = height or layout.get('height') or 800
# Export to bytes
image_bytes = pio.to_image(
fig,
format=format,
width=export_width,
height=export_height,
scale=scale
)
result = {
"format": format,
"dimensions": {
"width": export_width,
"height": export_height,
"scale": scale,
"effective_width": int(export_width * scale),
"effective_height": int(export_height * scale)
}
}
# Save to file or return base64
if output_path:
# Ensure directory exists
output_dir = os.path.dirname(output_path)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# Add extension if missing
if not output_path.endswith(f'.{format}'):
output_path = f"{output_path}.{format}"
with open(output_path, 'wb') as f:
f.write(image_bytes)
result["file_path"] = output_path
result["file_size_bytes"] = len(image_bytes)
else:
# Return as base64
result["image_data"] = base64.b64encode(image_bytes).decode('utf-8')
result["data_uri"] = f"data:image/{format};base64,{result['image_data']}"
return result
except ImportError as e:
logger.error(f"Chart export failed - missing dependency: {e}")
return {
"error": f"Missing dependency for export: {e}",
"format": format,
"image_data": None,
"install_hint": "pip install plotly kaleido"
}
except Exception as e:
logger.error(f"Chart export failed: {e}")
return {
"error": str(e),
"format": format,
"image_data": None
}

View File

@@ -10,6 +10,46 @@ from uuid import uuid4
logger = logging.getLogger(__name__)
# Standard responsive breakpoints (Mantine/Bootstrap-aligned)
DEFAULT_BREAKPOINTS = {
"xs": {
"min_width": "0px",
"max_width": "575px",
"cols": 1,
"spacing": "xs",
"description": "Extra small devices (phones, portrait)"
},
"sm": {
"min_width": "576px",
"max_width": "767px",
"cols": 2,
"spacing": "sm",
"description": "Small devices (phones, landscape)"
},
"md": {
"min_width": "768px",
"max_width": "991px",
"cols": 6,
"spacing": "md",
"description": "Medium devices (tablets)"
},
"lg": {
"min_width": "992px",
"max_width": "1199px",
"cols": 12,
"spacing": "md",
"description": "Large devices (desktops)"
},
"xl": {
"min_width": "1200px",
"max_width": None,
"cols": 12,
"spacing": "lg",
"description": "Extra large devices (large desktops)"
}
}
# Layout templates
TEMPLATES = {
"dashboard": {
@@ -365,3 +405,149 @@ class LayoutTools:
}
for name, config in FILTER_TYPES.items()
}
async def layout_set_breakpoints(
self,
layout_ref: str,
breakpoints: Dict[str, Dict[str, Any]],
mobile_first: bool = True
) -> Dict[str, Any]:
"""
Configure responsive breakpoints for a layout.
Args:
layout_ref: Layout name to configure
breakpoints: Breakpoint configuration dict:
{
"xs": {"cols": 1, "spacing": "xs"},
"sm": {"cols": 2, "spacing": "sm"},
"md": {"cols": 6, "spacing": "md"},
"lg": {"cols": 12, "spacing": "md"},
"xl": {"cols": 12, "spacing": "lg"}
}
mobile_first: If True, use min-width media queries (default)
Returns:
Dict with:
- breakpoints: Complete breakpoint configuration
- css_media_queries: Generated CSS media queries
- mobile_first: Whether mobile-first approach is used
"""
# Validate layout exists
if layout_ref not in self._layouts:
return {
"error": f"Layout '{layout_ref}' not found. Create it first with layout_create.",
"breakpoints": None
}
layout = self._layouts[layout_ref]
# Validate breakpoint names
valid_breakpoints = ["xs", "sm", "md", "lg", "xl"]
for bp_name in breakpoints.keys():
if bp_name not in valid_breakpoints:
return {
"error": f"Invalid breakpoint '{bp_name}'. Must be one of: {valid_breakpoints}",
"breakpoints": layout.get("breakpoints")
}
# Merge with defaults
merged_breakpoints = {}
for bp_name in valid_breakpoints:
default = DEFAULT_BREAKPOINTS[bp_name].copy()
if bp_name in breakpoints:
default.update(breakpoints[bp_name])
merged_breakpoints[bp_name] = default
# Validate spacing values
valid_spacing = ["xs", "sm", "md", "lg", "xl"]
for bp_name, bp_config in merged_breakpoints.items():
if "spacing" in bp_config and bp_config["spacing"] not in valid_spacing:
return {
"error": f"Invalid spacing '{bp_config['spacing']}' for breakpoint '{bp_name}'. Must be one of: {valid_spacing}",
"breakpoints": layout.get("breakpoints")
}
# Validate column counts
for bp_name, bp_config in merged_breakpoints.items():
if "cols" in bp_config:
cols = bp_config["cols"]
if not isinstance(cols, int) or cols < 1 or cols > 24:
return {
"error": f"Invalid cols '{cols}' for breakpoint '{bp_name}'. Must be integer between 1 and 24.",
"breakpoints": layout.get("breakpoints")
}
# Generate CSS media queries
css_queries = self._generate_media_queries(merged_breakpoints, mobile_first)
# Store in layout
layout["breakpoints"] = merged_breakpoints
layout["mobile_first"] = mobile_first
layout["responsive_css"] = css_queries
return {
"layout_ref": layout_ref,
"breakpoints": merged_breakpoints,
"mobile_first": mobile_first,
"css_media_queries": css_queries
}
def _generate_media_queries(
self,
breakpoints: Dict[str, Dict[str, Any]],
mobile_first: bool
) -> List[str]:
"""Generate CSS media queries for breakpoints."""
queries = []
bp_order = ["xs", "sm", "md", "lg", "xl"]
if mobile_first:
# Use min-width queries (mobile-first)
for bp_name in bp_order[1:]: # Skip xs (base styles)
bp = breakpoints[bp_name]
min_width = bp.get("min_width", DEFAULT_BREAKPOINTS[bp_name]["min_width"])
if min_width and min_width != "0px":
queries.append(f"@media (min-width: {min_width}) {{ /* {bp_name} styles */ }}")
else:
# Use max-width queries (desktop-first)
for bp_name in reversed(bp_order[:-1]): # Skip xl (base styles)
bp = breakpoints[bp_name]
max_width = bp.get("max_width", DEFAULT_BREAKPOINTS[bp_name]["max_width"])
if max_width:
queries.append(f"@media (max-width: {max_width}) {{ /* {bp_name} styles */ }}")
return queries
async def layout_get_breakpoints(self, layout_ref: str) -> Dict[str, Any]:
"""
Get the breakpoint configuration for a layout.
Args:
layout_ref: Layout name
Returns:
Dict with breakpoint configuration
"""
if layout_ref not in self._layouts:
return {
"error": f"Layout '{layout_ref}' not found.",
"breakpoints": None
}
layout = self._layouts[layout_ref]
return {
"layout_ref": layout_ref,
"breakpoints": layout.get("breakpoints", DEFAULT_BREAKPOINTS.copy()),
"mobile_first": layout.get("mobile_first", True),
"css_media_queries": layout.get("responsive_css", [])
}
def get_default_breakpoints(self) -> Dict[str, Any]:
"""Get the default breakpoint configuration."""
return {
"breakpoints": DEFAULT_BREAKPOINTS.copy(),
"description": "Standard responsive breakpoints aligned with Mantine/Bootstrap",
"mobile_first": True
}

View File

@@ -17,6 +17,7 @@ from .chart_tools import ChartTools
from .layout_tools import LayoutTools
from .theme_tools import ThemeTools
from .page_tools import PageTools
from .accessibility_tools import AccessibilityTools
# Suppress noisy MCP validation warnings on stderr
logging.basicConfig(level=logging.INFO)
@@ -36,6 +37,7 @@ class VizPlatformMCPServer:
self.layout_tools = LayoutTools()
self.theme_tools = ThemeTools()
self.page_tools = PageTools()
self.accessibility_tools = AccessibilityTools(theme_store=self.theme_tools.store)
async def initialize(self):
"""Initialize server and load configuration."""
@@ -198,6 +200,46 @@ class VizPlatformMCPServer:
}
))
# Chart export tool (Issue #247)
tools.append(Tool(
name="chart_export",
description=(
"Export a Plotly chart to static image format (PNG, SVG, PDF). "
"Requires kaleido package. Returns base64 image data or saves to file."
),
inputSchema={
"type": "object",
"properties": {
"figure": {
"type": "object",
"description": "Plotly figure JSON to export"
},
"format": {
"type": "string",
"enum": ["png", "svg", "pdf"],
"description": "Output format (default: png)"
},
"width": {
"type": "integer",
"description": "Image width in pixels (default: 1200)"
},
"height": {
"type": "integer",
"description": "Image height in pixels (default: 800)"
},
"scale": {
"type": "number",
"description": "Resolution scale factor (default: 2 for retina)"
},
"output_path": {
"type": "string",
"description": "Optional file path to save image"
}
},
"required": ["figure"]
}
))
# Layout tools (Issue #174)
tools.append(Tool(
name="layout_create",
@@ -280,6 +322,36 @@ class VizPlatformMCPServer:
}
))
# Responsive breakpoints tool (Issue #249)
tools.append(Tool(
name="layout_set_breakpoints",
description=(
"Configure responsive breakpoints for a layout. "
"Supports xs, sm, md, lg, xl breakpoints with mobile-first approach. "
"Each breakpoint can define cols, spacing, and other grid properties."
),
inputSchema={
"type": "object",
"properties": {
"layout_ref": {
"type": "string",
"description": "Layout name to configure"
},
"breakpoints": {
"type": "object",
"description": (
"Breakpoint config: {xs: {cols, spacing}, sm: {...}, md: {...}, lg: {...}, xl: {...}}"
)
},
"mobile_first": {
"type": "boolean",
"description": "Use mobile-first (min-width) media queries (default: true)"
}
},
"required": ["layout_ref", "breakpoints"]
}
))
# Theme tools (Issue #175)
tools.append(Tool(
name="theme_create",
@@ -451,6 +523,77 @@ class VizPlatformMCPServer:
}
))
# Accessibility tools (Issue #248)
tools.append(Tool(
name="accessibility_validate_colors",
description=(
"Validate colors for color blind accessibility. "
"Checks contrast ratios for deuteranopia, protanopia, tritanopia. "
"Returns issues, simulations, and accessible palette suggestions."
),
inputSchema={
"type": "object",
"properties": {
"colors": {
"type": "array",
"items": {"type": "string"},
"description": "List of hex colors to validate (e.g., ['#228be6', '#40c057'])"
},
"check_types": {
"type": "array",
"items": {"type": "string"},
"description": "Color blindness types to check: deuteranopia, protanopia, tritanopia (default: all)"
},
"min_contrast_ratio": {
"type": "number",
"description": "Minimum WCAG contrast ratio (default: 4.5 for AA)"
}
},
"required": ["colors"]
}
))
tools.append(Tool(
name="accessibility_validate_theme",
description=(
"Validate a theme's colors for accessibility. "
"Extracts all colors from theme tokens and checks for color blind safety."
),
inputSchema={
"type": "object",
"properties": {
"theme_name": {
"type": "string",
"description": "Theme name to validate"
}
},
"required": ["theme_name"]
}
))
tools.append(Tool(
name="accessibility_suggest_alternative",
description=(
"Suggest accessible alternative colors for a given color. "
"Provides alternatives optimized for specific color blindness types."
),
inputSchema={
"type": "object",
"properties": {
"color": {
"type": "string",
"description": "Hex color to find alternatives for"
},
"deficiency_type": {
"type": "string",
"enum": ["deuteranopia", "protanopia", "tritanopia"],
"description": "Color blindness type to optimize for"
}
},
"required": ["color", "deficiency_type"]
}
))
return tools
@self.server.call_tool()
@@ -524,6 +667,26 @@ class VizPlatformMCPServer:
text=json.dumps(result, indent=2)
)]
elif name == "chart_export":
figure = arguments.get('figure')
if not figure:
return [TextContent(
type="text",
text=json.dumps({"error": "figure is required"}, indent=2)
)]
result = await self.chart_tools.chart_export(
figure=figure,
format=arguments.get('format', 'png'),
width=arguments.get('width'),
height=arguments.get('height'),
scale=arguments.get('scale', 2.0),
output_path=arguments.get('output_path')
)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
# Layout tools
elif name == "layout_create":
layout_name = arguments.get('name')
@@ -568,6 +731,23 @@ class VizPlatformMCPServer:
text=json.dumps(result, indent=2)
)]
elif name == "layout_set_breakpoints":
layout_ref = arguments.get('layout_ref')
breakpoints = arguments.get('breakpoints', {})
mobile_first = arguments.get('mobile_first', True)
if not layout_ref:
return [TextContent(
type="text",
text=json.dumps({"error": "layout_ref is required"}, indent=2)
)]
result = await self.layout_tools.layout_set_breakpoints(
layout_ref, breakpoints, mobile_first
)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
# Theme tools
elif name == "theme_create":
theme_name = arguments.get('name')
@@ -669,6 +849,53 @@ class VizPlatformMCPServer:
text=json.dumps(result, indent=2)
)]
# Accessibility tools
elif name == "accessibility_validate_colors":
colors = arguments.get('colors')
if not colors:
return [TextContent(
type="text",
text=json.dumps({"error": "colors list is required"}, indent=2)
)]
result = await self.accessibility_tools.accessibility_validate_colors(
colors=colors,
check_types=arguments.get('check_types'),
min_contrast_ratio=arguments.get('min_contrast_ratio', 4.5)
)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
elif name == "accessibility_validate_theme":
theme_name = arguments.get('theme_name')
if not theme_name:
return [TextContent(
type="text",
text=json.dumps({"error": "theme_name is required"}, indent=2)
)]
result = await self.accessibility_tools.accessibility_validate_theme(theme_name)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
elif name == "accessibility_suggest_alternative":
color = arguments.get('color')
deficiency_type = arguments.get('deficiency_type')
if not color or not deficiency_type:
return [TextContent(
type="text",
text=json.dumps({"error": "color and deficiency_type are required"}, indent=2)
)]
result = await self.accessibility_tools.accessibility_suggest_alternative(
color, deficiency_type
)
return [TextContent(
type="text",
text=json.dumps(result, indent=2)
)]
raise ValueError(f"Unknown tool: {name}")
except Exception as e:

View File

@@ -5,6 +5,7 @@ mcp>=0.9.0
plotly>=5.18.0
dash>=2.14.0
dash-mantine-components>=2.0.0
kaleido>=0.2.1 # For chart export (PNG, SVG, PDF)
# Utilities
python-dotenv>=1.0.0

View File

@@ -0,0 +1,195 @@
"""
Tests for accessibility validation tools.
"""
import pytest
from mcp_server.accessibility_tools import AccessibilityTools
@pytest.fixture
def tools():
"""Create AccessibilityTools instance."""
return AccessibilityTools()
class TestHexToRgb:
"""Tests for _hex_to_rgb method."""
def test_hex_to_rgb_6_digit(self, tools):
"""Test 6-digit hex conversion."""
assert tools._hex_to_rgb("#FF0000") == (255, 0, 0)
assert tools._hex_to_rgb("#00FF00") == (0, 255, 0)
assert tools._hex_to_rgb("#0000FF") == (0, 0, 255)
def test_hex_to_rgb_3_digit(self, tools):
"""Test 3-digit hex conversion."""
assert tools._hex_to_rgb("#F00") == (255, 0, 0)
assert tools._hex_to_rgb("#0F0") == (0, 255, 0)
assert tools._hex_to_rgb("#00F") == (0, 0, 255)
def test_hex_to_rgb_lowercase(self, tools):
"""Test lowercase hex conversion."""
assert tools._hex_to_rgb("#ff0000") == (255, 0, 0)
class TestContrastRatio:
"""Tests for _get_contrast_ratio method."""
def test_black_white_contrast(self, tools):
"""Test black on white has maximum contrast."""
ratio = tools._get_contrast_ratio("#000000", "#FFFFFF")
assert ratio == pytest.approx(21.0, rel=0.01)
def test_same_color_contrast(self, tools):
"""Test same color has minimum contrast."""
ratio = tools._get_contrast_ratio("#FF0000", "#FF0000")
assert ratio == pytest.approx(1.0, rel=0.01)
def test_symmetric_contrast(self, tools):
"""Test contrast ratio is symmetric."""
ratio1 = tools._get_contrast_ratio("#228be6", "#FFFFFF")
ratio2 = tools._get_contrast_ratio("#FFFFFF", "#228be6")
assert ratio1 == pytest.approx(ratio2, rel=0.01)
class TestColorBlindnessSimulation:
"""Tests for _simulate_color_blindness method."""
def test_deuteranopia_simulation(self, tools):
"""Test deuteranopia (green-blind) simulation."""
# Red and green should appear more similar
original_red = "#FF0000"
original_green = "#00FF00"
simulated_red = tools._simulate_color_blindness(original_red, "deuteranopia")
simulated_green = tools._simulate_color_blindness(original_green, "deuteranopia")
# They should be different from originals
assert simulated_red != original_red or simulated_green != original_green
def test_protanopia_simulation(self, tools):
"""Test protanopia (red-blind) simulation."""
simulated = tools._simulate_color_blindness("#FF0000", "protanopia")
# Should return a modified color
assert simulated.startswith("#")
assert len(simulated) == 7
def test_tritanopia_simulation(self, tools):
"""Test tritanopia (blue-blind) simulation."""
simulated = tools._simulate_color_blindness("#0000FF", "tritanopia")
# Should return a modified color
assert simulated.startswith("#")
assert len(simulated) == 7
def test_unknown_deficiency_returns_original(self, tools):
"""Test unknown deficiency type returns original color."""
color = "#FF0000"
simulated = tools._simulate_color_blindness(color, "unknown")
assert simulated == color
class TestAccessibilityValidateColors:
"""Tests for accessibility_validate_colors method."""
@pytest.mark.asyncio
async def test_validate_single_color(self, tools):
"""Test validating a single color."""
result = await tools.accessibility_validate_colors(["#228be6"])
assert "colors_checked" in result
assert "overall_score" in result
assert "issues" in result
assert "safe_palettes" in result
@pytest.mark.asyncio
async def test_validate_problematic_colors(self, tools):
"""Test similar colors trigger warnings."""
# Use colors that are very close in hue, which should be harder to distinguish
result = await tools.accessibility_validate_colors(["#FF5555", "#FF6666"])
# Similar colors should trigger distinguishability warnings
assert "issues" in result
# The validation should at least run without errors
assert "colors_checked" in result
assert len(result["colors_checked"]) == 2
@pytest.mark.asyncio
async def test_validate_contrast_issue(self, tools):
"""Test low contrast colors trigger contrast warnings."""
# Yellow on white has poor contrast
result = await tools.accessibility_validate_colors(["#FFFF00"])
# Check for contrast issues (yellow may have issues with both black and white)
assert "issues" in result
@pytest.mark.asyncio
async def test_validate_with_specific_types(self, tools):
"""Test validating for specific color blindness types."""
result = await tools.accessibility_validate_colors(
["#FF0000", "#00FF00"],
check_types=["deuteranopia"]
)
assert "simulations" in result
assert "deuteranopia" in result["simulations"]
assert "protanopia" not in result["simulations"]
@pytest.mark.asyncio
async def test_overall_score(self, tools):
"""Test overall score is calculated."""
result = await tools.accessibility_validate_colors(["#228be6", "#ffffff"])
assert result["overall_score"] in ["A", "B", "C", "D"]
@pytest.mark.asyncio
async def test_recommendations_generated(self, tools):
"""Test recommendations are generated for issues."""
result = await tools.accessibility_validate_colors(["#FF0000", "#00FF00"])
assert "recommendations" in result
assert len(result["recommendations"]) > 0
class TestAccessibilitySuggestAlternative:
"""Tests for accessibility_suggest_alternative method."""
@pytest.mark.asyncio
async def test_suggest_alternative_deuteranopia(self, tools):
"""Test suggesting alternatives for deuteranopia."""
result = await tools.accessibility_suggest_alternative("#FF0000", "deuteranopia")
assert "original_color" in result
assert result["deficiency_type"] == "deuteranopia"
assert "suggestions" in result
assert len(result["suggestions"]) > 0
@pytest.mark.asyncio
async def test_suggest_alternative_tritanopia(self, tools):
"""Test suggesting alternatives for tritanopia."""
result = await tools.accessibility_suggest_alternative("#0000FF", "tritanopia")
assert "suggestions" in result
assert len(result["suggestions"]) > 0
@pytest.mark.asyncio
async def test_suggestions_include_safe_palettes(self, tools):
"""Test suggestions include colors from safe palettes."""
result = await tools.accessibility_suggest_alternative("#FF0000", "deuteranopia")
palette_suggestions = [
s for s in result["suggestions"]
if "palette" in s
]
assert len(palette_suggestions) > 0
class TestSafePalettes:
"""Tests for safe palette constants."""
def test_safe_palettes_exist(self, tools):
"""Test that safe palettes are defined."""
from mcp_server.accessibility_tools import SAFE_PALETTES
assert "categorical" in SAFE_PALETTES
assert "ibm" in SAFE_PALETTES
assert "okabe_ito" in SAFE_PALETTES
assert "tableau_colorblind" in SAFE_PALETTES
def test_safe_palettes_have_colors(self, tools):
"""Test that safe palettes have color lists."""
from mcp_server.accessibility_tools import SAFE_PALETTES
for palette_name, palette in SAFE_PALETTES.items():
assert "colors" in palette
assert len(palette["colors"]) > 0
# All colors should be valid hex
for color in palette["colors"]:
assert color.startswith("#")