palette.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. """A module for generating Radix colors.
  2. Converted from https://github.com/radix-ui/website/blob/main/components/generateRadixColors.tsx
  3. """
  4. import math
  5. from coloraide import Color
  6. from .bezier import bezier
  7. from .default_colors import DEFAULT_DARK_COLORS, DEFAULT_LIGHT_COLORS
  8. ArrayOf12 = list[float]
  9. gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
  10. scale_names = gray_scale_names + [
  11. "tomato",
  12. "red",
  13. "ruby",
  14. "crimson",
  15. "pink",
  16. "plum",
  17. "purple",
  18. "violet",
  19. "iris",
  20. "indigo",
  21. "blue",
  22. "cyan",
  23. "teal",
  24. "jade",
  25. "green",
  26. "grass",
  27. "brown",
  28. "orange",
  29. "sky",
  30. "mint",
  31. "lime",
  32. "yellow",
  33. "amber",
  34. ]
  35. def get_base_colors(appearance: str):
  36. """Get the base colors from the default dicts.
  37. Args:
  38. appearance: The appearance of the colors.
  39. Returns:
  40. dict: The base colors.
  41. """
  42. raw_colors = DEFAULT_LIGHT_COLORS if appearance == "light" else DEFAULT_DARK_COLORS
  43. colors = {}
  44. for color_name, color_scale in raw_colors.items():
  45. f_scale = []
  46. for scale in color_scale:
  47. scale["coords"] = [
  48. float("nan") if x is None else x for x in scale["coords"]
  49. ]
  50. f_scale.append(Color(scale))
  51. colors[color_name] = f_scale
  52. return colors
  53. light_colors = get_base_colors("light")
  54. dark_colors = get_base_colors("dark")
  55. light_gray_colors = {name: light_colors[name] for name in gray_scale_names}
  56. dark_gray_colors = {name: dark_colors[name] for name in gray_scale_names}
  57. def generate_radix_colors(
  58. appearance: str, accent: str, gray: str, background: str
  59. ) -> dict:
  60. """Generate Radix colors.
  61. Args:
  62. appearance: The appearance of the colors.
  63. accent: The accent color.
  64. gray: The gray color.
  65. background: The background color.
  66. Returns:
  67. dict: The generated colors.
  68. """
  69. all_scales = light_colors if appearance == "light" else dark_colors
  70. gray_scales = light_gray_colors if appearance == "light" else dark_gray_colors
  71. background_color = Color(background).convert("oklch")
  72. gray_base_color = Color(gray).convert("oklch")
  73. gray_scale_colors = get_scale_from_color(
  74. gray_base_color, gray_scales, background_color
  75. )
  76. accent_base_color = Color(accent).convert("oklch")
  77. accent_scale_colors = get_scale_from_color(
  78. accent_base_color, all_scales, background_color
  79. )
  80. background_hex = background_color.convert("srgb").to_string(hex=True)
  81. accent_base_hex = accent_base_color.convert("srgb").to_string(hex=True)
  82. if accent_base_hex == "#000000" or accent_base_hex == "#ffffff":
  83. accent_scale_colors = [color.clone() for color in gray_scale_colors]
  84. accent9_color, accent_contrast_color = get_step9_colors(
  85. accent_scale_colors, accent_base_color
  86. )
  87. accent_scale_colors[8] = accent9_color
  88. accent_scale_colors[9] = get_button_hover_color(
  89. accent9_color, [accent_scale_colors]
  90. )
  91. # Limit saturation of the text colors
  92. accent_scale_colors[10] = accent_scale_colors[10].set(
  93. "oklch.c",
  94. min(
  95. max(
  96. accent_scale_colors[8].get("oklch.c"),
  97. accent_scale_colors[7].get("oklch.c"),
  98. ),
  99. accent_scale_colors[10].get("oklch.c"),
  100. ),
  101. )
  102. accent_scale_colors[11] = accent_scale_colors[11].set(
  103. "oklch.c",
  104. min(
  105. max(
  106. accent_scale_colors[8].get("oklch.c"),
  107. accent_scale_colors[7].get("oklch.c"),
  108. ),
  109. accent_scale_colors[11].get("oklch.c"),
  110. ),
  111. )
  112. accent_scale_hex = [
  113. color.convert("srgb").to_string(hex=True) for color in accent_scale_colors
  114. ]
  115. accent_scale_wide_gamut = [to_oklch_string(color) for color in accent_scale_colors]
  116. accent_scale_alpha_hex = [
  117. get_alpha_color_srgb(color, background_hex) for color in accent_scale_hex
  118. ]
  119. accent_scale_alpha_wide_gamut_string = [
  120. get_alpha_color_p3(color, background_hex) for color in accent_scale_hex
  121. ]
  122. accent_contrast_color_hex = accent_contrast_color.convert("srgb").to_string(
  123. hex=True
  124. )
  125. gray_scale_hex = [
  126. color.convert("srgb").to_string(hex=True) for color in gray_scale_colors
  127. ]
  128. gray_scale_wide_gamut = [to_oklch_string(color) for color in gray_scale_colors]
  129. gray_scale_alpha_hex = [
  130. get_alpha_color_srgb(color, background_hex) for color in gray_scale_hex
  131. ]
  132. gray_scale_alpha_wide_gamut_string = [
  133. get_alpha_color_p3(color, background_hex) for color in gray_scale_hex
  134. ]
  135. accent_surface_hex = (
  136. get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.8)
  137. if appearance == "light"
  138. else get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.5)
  139. )
  140. accent_surface_wide_gamut_string = (
  141. get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.8)
  142. if appearance == "light"
  143. else get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.5)
  144. )
  145. return {
  146. "accentScale": accent_scale_hex,
  147. "accentScaleAlpha": accent_scale_alpha_hex,
  148. "accentScaleWideGamut": accent_scale_wide_gamut,
  149. "accentScaleAlphaWideGamut": accent_scale_alpha_wide_gamut_string,
  150. "accentContrast": accent_contrast_color_hex,
  151. "grayScale": gray_scale_hex,
  152. "grayScaleAlpha": gray_scale_alpha_hex,
  153. "grayScaleWideGamut": gray_scale_wide_gamut,
  154. "grayScaleAlphaWideGamut": gray_scale_alpha_wide_gamut_string,
  155. "graySurface": "#ffffffcc" if appearance == "light" else "rgba(0, 0, 0, 0.05)",
  156. "graySurfaceWideGamut": "color(display-p3 1 1 1 / 80%)"
  157. if appearance == "light"
  158. else "color(display-p3 0 0 0 / 5%)",
  159. "accentSurface": accent_surface_hex,
  160. "accentSurfaceWideGamut": accent_surface_wide_gamut_string,
  161. "background": background_hex,
  162. }
  163. def get_step9_colors(
  164. scale: list[Color], accent_base_color: Color
  165. ) -> tuple[Color, Color]:
  166. """Get the step 9 colors.
  167. Args:
  168. scale: The scale of colors.
  169. accent_base_color: The accent base color.
  170. Returns:
  171. The step 9 colors.
  172. """
  173. reference_background_color = scale[0]
  174. distance = accent_base_color.delta_e(reference_background_color) * 100
  175. if distance < 25:
  176. return scale[8], get_text_color(scale[8])
  177. return accent_base_color, get_text_color(accent_base_color)
  178. def get_button_hover_color(source: Color, scales: list[list[Color]]) -> Color:
  179. """Get the button hover color.
  180. Args:
  181. source: The source color.
  182. scales: The scales of colors.
  183. Returns:
  184. The button hover color.
  185. """
  186. L, C, H = source["lightness"], source["chroma"], source["hue"]
  187. new_L = L - 0.03 / (L + 0.1) if L > 0.4 else L + 0.03 / (L + 0.1)
  188. new_C = C * 0.93 if L > 0.4 and not math.isnan(H) else C
  189. button_hover_color = Color("oklch", [new_L, new_C, H])
  190. closest_color = button_hover_color
  191. min_distance = float("inf")
  192. for scale in scales:
  193. for color in scale:
  194. distance = button_hover_color.delta_e(color)
  195. if distance < min_distance:
  196. min_distance = distance
  197. closest_color = color
  198. button_hover_color["chroma"] = closest_color["chroma"]
  199. button_hover_color["hue"] = closest_color["hue"]
  200. return button_hover_color
  201. def get_text_color(background: Color) -> Color:
  202. """Get the text color.
  203. Args:
  204. background: The background color.
  205. Returns:
  206. The text color.
  207. """
  208. white = Color("oklch", [1, 0, 0])
  209. if abs(white.contrast(background)) < 40:
  210. _, C, H = background["lightness"], background["chroma"], background["hue"]
  211. return Color("oklch", [0.25, max(0.08 * C, 0.04), H])
  212. return white
  213. def get_alpha_color(
  214. target_rgb: list[float],
  215. background_rgb: list[float],
  216. rgb_precision: int,
  217. alpha_precision: int,
  218. target_alpha: float | None = None,
  219. ) -> tuple[float, float, float, float]:
  220. """Get the alpha color.
  221. Args:
  222. target_rgb: The target RGB.
  223. background_rgb: The background RGB.
  224. rgb_precision: The RGB precision.
  225. alpha_precision: The alpha precision.
  226. target_alpha: The target alpha.
  227. Raises:
  228. ValueError: If the color is undefined.
  229. Returns:
  230. The alpha color.
  231. """
  232. tr, tg, tb = [round(c * rgb_precision) for c in target_rgb]
  233. br, bg, bb = [round(c * rgb_precision) for c in background_rgb]
  234. if any(c is None for c in [tr, tg, tb, br, bg, bb]):
  235. raise ValueError("Color is undefined")
  236. desired_rgb = 0
  237. if tr > br or tg > bg or tb > bb:
  238. desired_rgb = rgb_precision
  239. alpha_r = (tr - br) / (desired_rgb - br)
  240. alpha_g = (tg - bg) / (desired_rgb - bg)
  241. alpha_b = (tb - bb) / (desired_rgb - bb)
  242. is_pure_gray = all(alpha == alpha_r for alpha in [alpha_r, alpha_g, alpha_b])
  243. if not target_alpha and is_pure_gray:
  244. v = desired_rgb / rgb_precision
  245. return v, v, v, alpha_r
  246. def clamp_rgb(n):
  247. return 0 if n is None else min(rgb_precision, max(0, n))
  248. def clamp_a(n):
  249. return 0 if n is None else min(alpha_precision, max(0, n))
  250. max_alpha = (
  251. target_alpha if target_alpha is not None else max(alpha_r, alpha_g, alpha_b)
  252. )
  253. A = clamp_a(math.ceil(max_alpha * alpha_precision)) / alpha_precision
  254. R = clamp_rgb(((br * (1 - A) - tr) / A) * -1)
  255. G = clamp_rgb(((bg * (1 - A) - tg) / A) * -1)
  256. B = clamp_rgb(((bb * (1 - A) - tb) / A) * -1)
  257. R, G, B = map(math.ceil, [R, G, B])
  258. blended_r = blend_alpha(R, A, br)
  259. blended_g = blend_alpha(G, A, bg)
  260. blended_b = blend_alpha(B, A, bb)
  261. if desired_rgb == 0:
  262. if tr <= br and tr != blended_r:
  263. R += 1 if tr > blended_r else -1
  264. if tg <= bg and tg != blended_g:
  265. G += 1 if tg > blended_g else -1
  266. if tb <= bb and tb != blended_b:
  267. B += 1 if tb > blended_b else -1
  268. if desired_rgb == rgb_precision:
  269. if tr >= br and tr != blended_r:
  270. R += 1 if tr > blended_r else -1
  271. if tg >= bg and tg != blended_g:
  272. G += 1 if tg > blended_g else -1
  273. if tb >= bb and tb != blended_b:
  274. B += 1 if tb > blended_b else -1
  275. R /= rgb_precision
  276. G /= rgb_precision
  277. B /= rgb_precision
  278. return R, G, B, A
  279. def blend_alpha(foreground, alpha, background, _round=True) -> float:
  280. """Blend the alpha.
  281. Args:
  282. foreground: The foreground.
  283. alpha: The alpha.
  284. background: The background.
  285. _round: Whether to round the result.
  286. Returns:
  287. The blended alpha.
  288. """
  289. if _round:
  290. return round(background * (1 - alpha)) + round(foreground * alpha)
  291. return background * (1 - alpha) + foreground * alpha
  292. def get_alpha_color_srgb(
  293. target_color: str, background_color: str, target_alpha: float | None = None
  294. ) -> str:
  295. """Get the alpha color in srgb.
  296. Args:
  297. target_color: The target color.
  298. background_color: The background color.
  299. target_alpha: The target alpha.
  300. Returns:
  301. The alpha color.
  302. """
  303. r, g, b, a = get_alpha_color(
  304. Color(target_color).convert("srgb").coords(),
  305. Color(background_color).convert("srgb").coords(),
  306. 255,
  307. 255,
  308. target_alpha,
  309. )
  310. return Color("srgb", [r, g, b], a).to_string(format="hex")
  311. def get_alpha_color_p3(
  312. target_color: str, background_color: str, target_alpha: float | None = None
  313. ) -> str:
  314. """Get the alpha color in display-p3.
  315. Args:
  316. target_color: The target color.
  317. background_color: The background color.
  318. target_alpha: The target alpha.
  319. Returns:
  320. The alpha color.
  321. """
  322. r, g, b, a = get_alpha_color(
  323. Color(target_color).convert("display-p3").coords(),
  324. Color(background_color).convert("display-p3").coords(),
  325. 255,
  326. 1000,
  327. target_alpha,
  328. )
  329. return Color("display-p3", [r, g, b], a).to_string(precision=4)
  330. def format_hex(s: str) -> str:
  331. """Format shortform hex to longform.
  332. Args:
  333. s: The hex color.
  334. Returns:
  335. The formatted hex color.
  336. """
  337. if not s.startswith("#"):
  338. return s
  339. if len(s) == 4:
  340. return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}"
  341. if len(s) == 5:
  342. return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}{s[4]}{s[4]}"
  343. return s
  344. dark_mode_easing = [1, 0, 1, 0]
  345. light_mode_easing = [0, 2, 0, 2]
  346. def to_oklch_string(color: Color) -> str:
  347. """Convert a color to an oklch string for CSS.
  348. Args:
  349. color: The color to convert.
  350. Returns:
  351. The oklch string.
  352. """
  353. L = round(color["lightness"] * 100, 1)
  354. return f"oklch({L}% {color['chroma']:.4f} {color['hue']:.4f})"
  355. def get_scale_from_color(
  356. source: Color, scales: dict[str, list[Color]], background_color: Color
  357. ) -> list[Color]:
  358. """Get a scale from a color.
  359. Args:
  360. source: The source color.
  361. scales: The scales of colors.
  362. background_color: The background color.
  363. Returns:
  364. The generated scale.
  365. """
  366. all_colors = []
  367. for name, scale in scales.items():
  368. for color in scale:
  369. distance = source.delta_e(color)
  370. all_colors.append({"scale": name, "distance": distance, "color": color})
  371. all_colors.sort(key=lambda x: x["distance"])
  372. # Remove non-unique scales
  373. closest_colors = []
  374. seen_scales = set()
  375. for color in all_colors:
  376. if color["scale"] not in seen_scales:
  377. closest_colors.append(color)
  378. seen_scales.add(color["scale"])
  379. # Handle gray scales
  380. gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
  381. all_are_grays = all(color["scale"] in gray_scale_names for color in closest_colors)
  382. if not all_are_grays and closest_colors[0]["scale"] in gray_scale_names:
  383. while closest_colors[1]["scale"] in gray_scale_names:
  384. del closest_colors[1]
  385. color_a = closest_colors[0]
  386. color_b = closest_colors[1]
  387. # Calculate triangle sides
  388. a = color_b["distance"]
  389. b = color_a["distance"]
  390. c = color_a["color"].delta_e(color_b["color"])
  391. # Calculate angles
  392. cos_a = (b**2 + c**2 - a**2) / (2 * b * c)
  393. rad_a = math.acos(cos_a)
  394. sin_a = math.sin(rad_a)
  395. cos_b = (a**2 + c**2 - b**2) / (2 * a * c)
  396. rad_b = math.acos(cos_b)
  397. sin_b = math.sin(rad_b)
  398. # Calculate tangents
  399. tan_c1 = cos_a / sin_a
  400. tan_c2 = cos_b / sin_b
  401. # Calculate ratio
  402. ratio = max(0, tan_c1 / tan_c2) * 0.5
  403. # Mix scales
  404. scale_a = scales[color_a["scale"]]
  405. scale_b = scales[color_b["scale"]]
  406. scale = [
  407. Color.mix(scale_a[i], scale_b[i], ratio).convert("oklch") for i in range(12)
  408. ]
  409. # Find base color
  410. base_color = min(scale, key=lambda color: source.delta_e(color))
  411. # Adjust chroma ratio
  412. ratio_c = source.get("oklch.c") / base_color.get("oklch.c")
  413. # Modify hue and chroma of the scale
  414. for color in scale:
  415. color = color.set(
  416. "oklch.c", min(source.get("oklch.c") * 1.5, color.get("oklch.c") * ratio_c)
  417. )
  418. color = color.set("oklch.h", source.get("oklch.h"))
  419. # Handle light and dark modes
  420. if scale[0].get("oklch.l") > 0.5: # Light mode
  421. lightness_scale = [color.get("oklch.l") for color in scale]
  422. background_l = max(0, min(1, background_color.get("oklch.l")))
  423. new_lightness_scale = transpose_progression_start(
  424. background_l, lightness_scale, light_mode_easing
  425. )
  426. new_lightness_scale = new_lightness_scale[1:] # Remove the added step
  427. for i, lightness in enumerate(new_lightness_scale):
  428. scale[i] = scale[i].set("oklch.l", lightness)
  429. else: # Dark mode
  430. ease = list(dark_mode_easing)
  431. reference_background_color_l = scale[0].get("oklch.l")
  432. background_color_l = max(0, min(1, background_color.get("oklch.l")))
  433. ratio_l = background_color_l / reference_background_color_l
  434. if ratio_l > 1:
  435. max_ratio = 1.5
  436. for i in range(len(ease)):
  437. meta_ratio = (ratio_l - 1) * (max_ratio / (max_ratio - 1))
  438. ease[i] = ( # type: ignore
  439. 0 if ratio_l > max_ratio else max(0, ease[i] * (1 - meta_ratio))
  440. )
  441. lightness_scale = [color.get("oklch.l") for color in scale]
  442. background_l = background_color.get("oklch.l")
  443. new_lightness_scale = transpose_progression_start(
  444. background_l, lightness_scale, ease
  445. )
  446. for i, lightness in enumerate(new_lightness_scale):
  447. scale[i] = scale[i].set("oklch.l", lightness)
  448. return scale
  449. def transpose_progression_start(to: float, arr: list, curve: list) -> list[float]:
  450. """Transpose a progression to a new start point.
  451. Args:
  452. to: The new start point.
  453. arr: The progression.
  454. curve: The bezier curve.
  455. Returns:
  456. The transposed progression.
  457. """
  458. last_index = len(arr) - 1
  459. diff = arr[0] - to
  460. fn = bezier(*curve)
  461. return [n - diff * fn(1 - i / last_index) for i, n in enumerate(arr)]
  462. def transpose_progression_end(
  463. to: float, arr: list[float], curve: list[float]
  464. ) -> list[float]:
  465. """Transpose a progression to a new end point.
  466. Args:
  467. to: The new end point.
  468. arr: The progression.
  469. curve: The bezier curve.
  470. Returns:
  471. The transposed progression.
  472. """
  473. last_index = len(arr) - 1
  474. diff = arr[-1] - to
  475. fn = bezier(*curve)
  476. return [n - diff * fn(i / last_index) for i, n in enumerate(arr)]