Coverage for src / idx_api / utils / colors.py: 26%
38 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-28 11:09 -0700
1"""Color palette generation for dynamic theming.
3Generates Tailwind-compatible 11-shade color palettes from a single hex color.
4Uses Python's built-in colorsys module - no external dependencies required.
5"""
7import colorsys
8from typing import TypedDict
11class ColorPalette(TypedDict):
12 """Tailwind-compatible 11-shade color palette."""
14 # Using string keys to match Tailwind's numeric shade names
15 pass # TypedDict with string keys defined below
18# Tailwind default lightness targets (approximate values that produce good palettes)
19SHADE_LIGHTNESS: dict[str, float] = {
20 "50": 0.97,
21 "100": 0.94,
22 "200": 0.86,
23 "300": 0.73,
24 "400": 0.58,
25 "500": 0.45,
26 "600": 0.37,
27 "700": 0.29,
28 "800": 0.24,
29 "900": 0.19,
30 "950": 0.10,
31}
33# Default fallback palette (sky blue - matches Tailwind's default primary)
34DEFAULT_PALETTE: dict[str, str] = {
35 "50": "#f0f9ff",
36 "100": "#e0f2fe",
37 "200": "#bae6fd",
38 "300": "#7dd3fc",
39 "400": "#38bdf8",
40 "500": "#0ea5e9",
41 "600": "#0284c7",
42 "700": "#0369a1",
43 "800": "#075985",
44 "900": "#0c4a6e",
45 "950": "#082f49",
46}
49def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
50 """Convert hex color to RGB tuple.
52 Args:
53 hex_color: Hex color string (with or without leading #)
55 Returns:
56 Tuple of (red, green, blue) values 0-255
57 """
58 hex_color = hex_color.lstrip("#")
59 if len(hex_color) != 6:
60 raise ValueError(f"Invalid hex color: {hex_color}")
61 return (
62 int(hex_color[0:2], 16),
63 int(hex_color[2:4], 16),
64 int(hex_color[4:6], 16),
65 )
68def rgb_to_hex(r: int, g: int, b: int) -> str:
69 """Convert RGB tuple to hex color.
71 Args:
72 r: Red value 0-255
73 g: Green value 0-255
74 b: Blue value 0-255
76 Returns:
77 Hex color string with leading #
78 """
79 return f"#{r:02x}{g:02x}{b:02x}"
82def generate_palette(base_hex: str) -> dict[str, str]:
83 """Generate Tailwind-compatible 11-shade palette from a single hex color.
85 Uses HSL color space to create consistent shade variations. The base color
86 is analyzed, then each shade is generated by adjusting lightness while
87 preserving hue and slightly modifying saturation for natural-looking results.
89 Args:
90 base_hex: Base hex color (e.g., "#ee7711" or "ee7711")
92 Returns:
93 Dictionary with shade names ("50", "100", ..., "950") as keys
94 and hex color values as strings
96 Example:
97 >>> generate_palette("#ee7711")
98 {'50': '#fef7ee', '100': '#fdedd3', ..., '950': '#401506'}
99 """
100 try:
101 r, g, b = hex_to_rgb(base_hex)
102 except (ValueError, IndexError):
103 # Invalid hex color - return default palette
104 return DEFAULT_PALETTE.copy()
106 # Convert to HLS (Python's colorsys uses HLS, not HSL)
107 # Note: colorsys uses 0-1 range for all values
108 h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
110 palette: dict[str, str] = {}
112 for shade, target_l in SHADE_LIGHTNESS.items():
113 # Adjust saturation for extreme lightness values
114 # Very light/dark shades look better with reduced saturation
115 lightness_distance = abs(target_l - 0.5)
116 sat_factor = 1.0 - (lightness_distance * 0.5)
117 new_s = min(1.0, max(0.0, s * sat_factor))
119 # Ensure lightness is within valid range
120 new_l = max(0.0, min(1.0, target_l))
122 # Convert back to RGB
123 new_r, new_g, new_b = colorsys.hls_to_rgb(h, new_l, new_s)
125 # Scale back to 0-255 and convert to hex
126 palette[shade] = rgb_to_hex(
127 int(round(new_r * 255)),
128 int(round(new_g * 255)),
129 int(round(new_b * 255)),
130 )
132 return palette
135def get_contrasting_text_color(hex_color: str) -> str:
136 """Determine whether white or black text provides better contrast.
138 Uses the relative luminance formula from WCAG 2.0.
140 Args:
141 hex_color: Background hex color
143 Returns:
144 "#ffffff" (white) or "#000000" (black)
145 """
146 try:
147 r, g, b = hex_to_rgb(hex_color)
148 except (ValueError, IndexError):
149 return "#000000"
151 # Calculate relative luminance (sRGB)
152 def channel_luminance(c: int) -> float:
153 c_norm = c / 255
154 return c_norm / 12.92 if c_norm <= 0.04045 else ((c_norm + 0.055) / 1.055) ** 2.4
156 luminance = (
157 0.2126 * channel_luminance(r)
158 + 0.7152 * channel_luminance(g)
159 + 0.0722 * channel_luminance(b)
160 )
162 # Use white text on dark backgrounds, black on light
163 return "#ffffff" if luminance < 0.5 else "#000000"