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

1"""Color palette generation for dynamic theming. 

2 

3Generates Tailwind-compatible 11-shade color palettes from a single hex color. 

4Uses Python's built-in colorsys module - no external dependencies required. 

5""" 

6 

7import colorsys 

8from typing import TypedDict 

9 

10 

11class ColorPalette(TypedDict): 

12 """Tailwind-compatible 11-shade color palette.""" 

13 

14 # Using string keys to match Tailwind's numeric shade names 

15 pass # TypedDict with string keys defined below 

16 

17 

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} 

32 

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} 

47 

48 

49def hex_to_rgb(hex_color: str) -> tuple[int, int, int]: 

50 """Convert hex color to RGB tuple. 

51 

52 Args: 

53 hex_color: Hex color string (with or without leading #) 

54 

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 ) 

66 

67 

68def rgb_to_hex(r: int, g: int, b: int) -> str: 

69 """Convert RGB tuple to hex color. 

70 

71 Args: 

72 r: Red value 0-255 

73 g: Green value 0-255 

74 b: Blue value 0-255 

75 

76 Returns: 

77 Hex color string with leading # 

78 """ 

79 return f"#{r:02x}{g:02x}{b:02x}" 

80 

81 

82def generate_palette(base_hex: str) -> dict[str, str]: 

83 """Generate Tailwind-compatible 11-shade palette from a single hex color. 

84 

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. 

88 

89 Args: 

90 base_hex: Base hex color (e.g., "#ee7711" or "ee7711") 

91 

92 Returns: 

93 Dictionary with shade names ("50", "100", ..., "950") as keys 

94 and hex color values as strings 

95 

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() 

105 

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) 

109 

110 palette: dict[str, str] = {} 

111 

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)) 

118 

119 # Ensure lightness is within valid range 

120 new_l = max(0.0, min(1.0, target_l)) 

121 

122 # Convert back to RGB 

123 new_r, new_g, new_b = colorsys.hls_to_rgb(h, new_l, new_s) 

124 

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 ) 

131 

132 return palette 

133 

134 

135def get_contrasting_text_color(hex_color: str) -> str: 

136 """Determine whether white or black text provides better contrast. 

137 

138 Uses the relative luminance formula from WCAG 2.0. 

139 

140 Args: 

141 hex_color: Background hex color 

142 

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" 

150 

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 

155 

156 luminance = ( 

157 0.2126 * channel_luminance(r) 

158 + 0.7152 * channel_luminance(g) 

159 + 0.0722 * channel_luminance(b) 

160 ) 

161 

162 # Use white text on dark backgrounds, black on light 

163 return "#ffffff" if luminance < 0.5 else "#000000"