123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600 |
- """A module for generating Radix colors.
- Converted from https://github.com/radix-ui/website/blob/main/components/generateRadixColors.tsx
- """
- import math
- from coloraide import Color
- from .bezier import bezier
- from .default_colors import DEFAULT_DARK_COLORS, DEFAULT_LIGHT_COLORS
- ArrayOf12 = list[float]
- gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
- scale_names = gray_scale_names + [
- "tomato",
- "red",
- "ruby",
- "crimson",
- "pink",
- "plum",
- "purple",
- "violet",
- "iris",
- "indigo",
- "blue",
- "cyan",
- "teal",
- "jade",
- "green",
- "grass",
- "brown",
- "orange",
- "sky",
- "mint",
- "lime",
- "yellow",
- "amber",
- ]
- def get_base_colors(appearance: str):
- """Get the base colors from the default dicts.
- Args:
- appearance: The appearance of the colors.
- Returns:
- dict: The base colors.
- """
- raw_colors = DEFAULT_LIGHT_COLORS if appearance == "light" else DEFAULT_DARK_COLORS
- colors = {}
- for color_name, color_scale in raw_colors.items():
- f_scale = []
- for scale in color_scale:
- scale["coords"] = [
- float("nan") if x is None else x for x in scale["coords"]
- ]
- f_scale.append(Color(scale))
- colors[color_name] = f_scale
- return colors
- light_colors = get_base_colors("light")
- dark_colors = get_base_colors("dark")
- light_gray_colors = {name: light_colors[name] for name in gray_scale_names}
- dark_gray_colors = {name: dark_colors[name] for name in gray_scale_names}
- def generate_radix_colors(
- appearance: str, accent: str, gray: str, background: str
- ) -> dict:
- """Generate Radix colors.
- Args:
- appearance: The appearance of the colors.
- accent: The accent color.
- gray: The gray color.
- background: The background color.
- Returns:
- dict: The generated colors.
- """
- all_scales = light_colors if appearance == "light" else dark_colors
- gray_scales = light_gray_colors if appearance == "light" else dark_gray_colors
- background_color = Color(background).convert("oklch")
- gray_base_color = Color(gray).convert("oklch")
- gray_scale_colors = get_scale_from_color(
- gray_base_color, gray_scales, background_color
- )
- accent_base_color = Color(accent).convert("oklch")
- accent_scale_colors = get_scale_from_color(
- accent_base_color, all_scales, background_color
- )
- background_hex = background_color.convert("srgb").to_string(hex=True)
- accent_base_hex = accent_base_color.convert("srgb").to_string(hex=True)
- if accent_base_hex == "#000000" or accent_base_hex == "#ffffff":
- accent_scale_colors = [color.clone() for color in gray_scale_colors]
- accent9_color, accent_contrast_color = get_step9_colors(
- accent_scale_colors, accent_base_color
- )
- accent_scale_colors[8] = accent9_color
- accent_scale_colors[9] = get_button_hover_color(
- accent9_color, [accent_scale_colors]
- )
- # Limit saturation of the text colors
- accent_scale_colors[10] = accent_scale_colors[10].set(
- "oklch.c",
- min(
- max(
- accent_scale_colors[8].get("oklch.c"),
- accent_scale_colors[7].get("oklch.c"),
- ),
- accent_scale_colors[10].get("oklch.c"),
- ),
- )
- accent_scale_colors[11] = accent_scale_colors[11].set(
- "oklch.c",
- min(
- max(
- accent_scale_colors[8].get("oklch.c"),
- accent_scale_colors[7].get("oklch.c"),
- ),
- accent_scale_colors[11].get("oklch.c"),
- ),
- )
- accent_scale_hex = [
- color.convert("srgb").to_string(hex=True) for color in accent_scale_colors
- ]
- accent_scale_wide_gamut = [to_oklch_string(color) for color in accent_scale_colors]
- accent_scale_alpha_hex = [
- get_alpha_color_srgb(color, background_hex) for color in accent_scale_hex
- ]
- accent_scale_alpha_wide_gamut_string = [
- get_alpha_color_p3(color, background_hex) for color in accent_scale_hex
- ]
- accent_contrast_color_hex = accent_contrast_color.convert("srgb").to_string(
- hex=True
- )
- gray_scale_hex = [
- color.convert("srgb").to_string(hex=True) for color in gray_scale_colors
- ]
- gray_scale_wide_gamut = [to_oklch_string(color) for color in gray_scale_colors]
- gray_scale_alpha_hex = [
- get_alpha_color_srgb(color, background_hex) for color in gray_scale_hex
- ]
- gray_scale_alpha_wide_gamut_string = [
- get_alpha_color_p3(color, background_hex) for color in gray_scale_hex
- ]
- accent_surface_hex = (
- get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.8)
- if appearance == "light"
- else get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.5)
- )
- accent_surface_wide_gamut_string = (
- get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.8)
- if appearance == "light"
- else get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.5)
- )
- return {
- "accentScale": accent_scale_hex,
- "accentScaleAlpha": accent_scale_alpha_hex,
- "accentScaleWideGamut": accent_scale_wide_gamut,
- "accentScaleAlphaWideGamut": accent_scale_alpha_wide_gamut_string,
- "accentContrast": accent_contrast_color_hex,
- "grayScale": gray_scale_hex,
- "grayScaleAlpha": gray_scale_alpha_hex,
- "grayScaleWideGamut": gray_scale_wide_gamut,
- "grayScaleAlphaWideGamut": gray_scale_alpha_wide_gamut_string,
- "graySurface": "#ffffffcc" if appearance == "light" else "rgba(0, 0, 0, 0.05)",
- "graySurfaceWideGamut": "color(display-p3 1 1 1 / 80%)"
- if appearance == "light"
- else "color(display-p3 0 0 0 / 5%)",
- "accentSurface": accent_surface_hex,
- "accentSurfaceWideGamut": accent_surface_wide_gamut_string,
- "background": background_hex,
- }
- def get_step9_colors(
- scale: list[Color], accent_base_color: Color
- ) -> tuple[Color, Color]:
- """Get the step 9 colors.
- Args:
- scale: The scale of colors.
- accent_base_color: The accent base color.
- Returns:
- The step 9 colors.
- """
- reference_background_color = scale[0]
- distance = accent_base_color.delta_e(reference_background_color) * 100
- if distance < 25:
- return scale[8], get_text_color(scale[8])
- return accent_base_color, get_text_color(accent_base_color)
- def get_button_hover_color(source: Color, scales: list[list[Color]]) -> Color:
- """Get the button hover color.
- Args:
- source: The source color.
- scales: The scales of colors.
- Returns:
- The button hover color.
- """
- L, C, H = source["lightness"], source["chroma"], source["hue"]
- new_L = L - 0.03 / (L + 0.1) if L > 0.4 else L + 0.03 / (L + 0.1)
- new_C = C * 0.93 if L > 0.4 and not math.isnan(H) else C
- button_hover_color = Color("oklch", [new_L, new_C, H])
- closest_color = button_hover_color
- min_distance = float("inf")
- for scale in scales:
- for color in scale:
- distance = button_hover_color.delta_e(color)
- if distance < min_distance:
- min_distance = distance
- closest_color = color
- button_hover_color["chroma"] = closest_color["chroma"]
- button_hover_color["hue"] = closest_color["hue"]
- return button_hover_color
- def get_text_color(background: Color) -> Color:
- """Get the text color.
- Args:
- background: The background color.
- Returns:
- The text color.
- """
- white = Color("oklch", [1, 0, 0])
- if abs(white.contrast(background)) < 40:
- _, C, H = background["lightness"], background["chroma"], background["hue"]
- return Color("oklch", [0.25, max(0.08 * C, 0.04), H])
- return white
- def get_alpha_color(
- target_rgb: list[float],
- background_rgb: list[float],
- rgb_precision: int,
- alpha_precision: int,
- target_alpha: float | None = None,
- ) -> tuple[float, float, float, float]:
- """Get the alpha color.
- Args:
- target_rgb: The target RGB.
- background_rgb: The background RGB.
- rgb_precision: The RGB precision.
- alpha_precision: The alpha precision.
- target_alpha: The target alpha.
- Raises:
- ValueError: If the color is undefined.
- Returns:
- The alpha color.
- """
- tr, tg, tb = [round(c * rgb_precision) for c in target_rgb]
- br, bg, bb = [round(c * rgb_precision) for c in background_rgb]
- if any(c is None for c in [tr, tg, tb, br, bg, bb]):
- raise ValueError("Color is undefined")
- desired_rgb = 0
- if tr > br or tg > bg or tb > bb:
- desired_rgb = rgb_precision
- alpha_r = (tr - br) / (desired_rgb - br)
- alpha_g = (tg - bg) / (desired_rgb - bg)
- alpha_b = (tb - bb) / (desired_rgb - bb)
- is_pure_gray = all(alpha == alpha_r for alpha in [alpha_r, alpha_g, alpha_b])
- if not target_alpha and is_pure_gray:
- v = desired_rgb / rgb_precision
- return v, v, v, alpha_r
- def clamp_rgb(n):
- return 0 if n is None else min(rgb_precision, max(0, n))
- def clamp_a(n):
- return 0 if n is None else min(alpha_precision, max(0, n))
- max_alpha = (
- target_alpha if target_alpha is not None else max(alpha_r, alpha_g, alpha_b)
- )
- A = clamp_a(math.ceil(max_alpha * alpha_precision)) / alpha_precision
- R = clamp_rgb(((br * (1 - A) - tr) / A) * -1)
- G = clamp_rgb(((bg * (1 - A) - tg) / A) * -1)
- B = clamp_rgb(((bb * (1 - A) - tb) / A) * -1)
- R, G, B = map(math.ceil, [R, G, B])
- blended_r = blend_alpha(R, A, br)
- blended_g = blend_alpha(G, A, bg)
- blended_b = blend_alpha(B, A, bb)
- if desired_rgb == 0:
- if tr <= br and tr != blended_r:
- R += 1 if tr > blended_r else -1
- if tg <= bg and tg != blended_g:
- G += 1 if tg > blended_g else -1
- if tb <= bb and tb != blended_b:
- B += 1 if tb > blended_b else -1
- if desired_rgb == rgb_precision:
- if tr >= br and tr != blended_r:
- R += 1 if tr > blended_r else -1
- if tg >= bg and tg != blended_g:
- G += 1 if tg > blended_g else -1
- if tb >= bb and tb != blended_b:
- B += 1 if tb > blended_b else -1
- R /= rgb_precision
- G /= rgb_precision
- B /= rgb_precision
- return R, G, B, A
- def blend_alpha(foreground, alpha, background, _round=True) -> float:
- """Blend the alpha.
- Args:
- foreground: The foreground.
- alpha: The alpha.
- background: The background.
- _round: Whether to round the result.
- Returns:
- The blended alpha.
- """
- if _round:
- return round(background * (1 - alpha)) + round(foreground * alpha)
- return background * (1 - alpha) + foreground * alpha
- def get_alpha_color_srgb(
- target_color: str, background_color: str, target_alpha: float | None = None
- ) -> str:
- """Get the alpha color in srgb.
- Args:
- target_color: The target color.
- background_color: The background color.
- target_alpha: The target alpha.
- Returns:
- The alpha color.
- """
- r, g, b, a = get_alpha_color(
- Color(target_color).convert("srgb").coords(),
- Color(background_color).convert("srgb").coords(),
- 255,
- 255,
- target_alpha,
- )
- return Color("srgb", [r, g, b], a).to_string(format="hex")
- def get_alpha_color_p3(
- target_color: str, background_color: str, target_alpha: float | None = None
- ) -> str:
- """Get the alpha color in display-p3.
- Args:
- target_color: The target color.
- background_color: The background color.
- target_alpha: The target alpha.
- Returns:
- The alpha color.
- """
- r, g, b, a = get_alpha_color(
- Color(target_color).convert("display-p3").coords(),
- Color(background_color).convert("display-p3").coords(),
- 255,
- 1000,
- target_alpha,
- )
- return Color("display-p3", [r, g, b], a).to_string(precision=4)
- def format_hex(s: str) -> str:
- """Format shortform hex to longform.
- Args:
- s: The hex color.
- Returns:
- The formatted hex color.
- """
- if not s.startswith("#"):
- return s
- if len(s) == 4:
- return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}"
- if len(s) == 5:
- return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}{s[4]}{s[4]}"
- return s
- dark_mode_easing = [1, 0, 1, 0]
- light_mode_easing = [0, 2, 0, 2]
- def to_oklch_string(color: Color) -> str:
- """Convert a color to an oklch string for CSS.
- Args:
- color: The color to convert.
- Returns:
- The oklch string.
- """
- L = round(color["lightness"] * 100, 1)
- return f"oklch({L}% {color['chroma']:.4f} {color['hue']:.4f})"
- def get_scale_from_color(
- source: Color, scales: dict[str, list[Color]], background_color: Color
- ) -> list[Color]:
- """Get a scale from a color.
- Args:
- source: The source color.
- scales: The scales of colors.
- background_color: The background color.
- Returns:
- The generated scale.
- """
- all_colors = []
- for name, scale in scales.items():
- for color in scale:
- distance = source.delta_e(color)
- all_colors.append({"scale": name, "distance": distance, "color": color})
- all_colors.sort(key=lambda x: x["distance"])
- # Remove non-unique scales
- closest_colors = []
- seen_scales = set()
- for color in all_colors:
- if color["scale"] not in seen_scales:
- closest_colors.append(color)
- seen_scales.add(color["scale"])
- # Handle gray scales
- gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
- all_are_grays = all(color["scale"] in gray_scale_names for color in closest_colors)
- if not all_are_grays and closest_colors[0]["scale"] in gray_scale_names:
- while closest_colors[1]["scale"] in gray_scale_names:
- del closest_colors[1]
- color_a = closest_colors[0]
- color_b = closest_colors[1]
- # Calculate triangle sides
- a = color_b["distance"]
- b = color_a["distance"]
- c = color_a["color"].delta_e(color_b["color"])
- # Calculate angles
- cos_a = (b**2 + c**2 - a**2) / (2 * b * c)
- rad_a = math.acos(cos_a)
- sin_a = math.sin(rad_a)
- cos_b = (a**2 + c**2 - b**2) / (2 * a * c)
- rad_b = math.acos(cos_b)
- sin_b = math.sin(rad_b)
- # Calculate tangents
- tan_c1 = cos_a / sin_a
- tan_c2 = cos_b / sin_b
- # Calculate ratio
- ratio = max(0, tan_c1 / tan_c2) * 0.5
- # Mix scales
- scale_a = scales[color_a["scale"]]
- scale_b = scales[color_b["scale"]]
- scale = [
- Color.mix(scale_a[i], scale_b[i], ratio).convert("oklch") for i in range(12)
- ]
- # Find base color
- base_color = min(scale, key=lambda color: source.delta_e(color))
- # Adjust chroma ratio
- ratio_c = source.get("oklch.c") / base_color.get("oklch.c")
- # Modify hue and chroma of the scale
- for color in scale:
- color = color.set(
- "oklch.c", min(source.get("oklch.c") * 1.5, color.get("oklch.c") * ratio_c)
- )
- color = color.set("oklch.h", source.get("oklch.h"))
- # Handle light and dark modes
- if scale[0].get("oklch.l") > 0.5: # Light mode
- lightness_scale = [color.get("oklch.l") for color in scale]
- background_l = max(0, min(1, background_color.get("oklch.l")))
- new_lightness_scale = transpose_progression_start(
- background_l, lightness_scale, light_mode_easing
- )
- new_lightness_scale = new_lightness_scale[1:] # Remove the added step
- for i, lightness in enumerate(new_lightness_scale):
- scale[i] = scale[i].set("oklch.l", lightness)
- else: # Dark mode
- ease = list(dark_mode_easing)
- reference_background_color_l = scale[0].get("oklch.l")
- background_color_l = max(0, min(1, background_color.get("oklch.l")))
- ratio_l = background_color_l / reference_background_color_l
- if ratio_l > 1:
- max_ratio = 1.5
- for i in range(len(ease)):
- meta_ratio = (ratio_l - 1) * (max_ratio / (max_ratio - 1))
- ease[i] = ( # type: ignore
- 0 if ratio_l > max_ratio else max(0, ease[i] * (1 - meta_ratio))
- )
- lightness_scale = [color.get("oklch.l") for color in scale]
- background_l = background_color.get("oklch.l")
- new_lightness_scale = transpose_progression_start(
- background_l, lightness_scale, ease
- )
- for i, lightness in enumerate(new_lightness_scale):
- scale[i] = scale[i].set("oklch.l", lightness)
- return scale
- def transpose_progression_start(to: float, arr: list, curve: list) -> list[float]:
- """Transpose a progression to a new start point.
- Args:
- to: The new start point.
- arr: The progression.
- curve: The bezier curve.
- Returns:
- The transposed progression.
- """
- last_index = len(arr) - 1
- diff = arr[0] - to
- fn = bezier(*curve)
- return [n - diff * fn(1 - i / last_index) for i, n in enumerate(arr)]
- def transpose_progression_end(
- to: float, arr: list[float], curve: list[float]
- ) -> list[float]:
- """Transpose a progression to a new end point.
- Args:
- to: The new end point.
- arr: The progression.
- curve: The bezier curve.
- Returns:
- The transposed progression.
- """
- last_index = len(arr) - 1
- diff = arr[-1] - to
- fn = bezier(*curve)
- return [n - diff * fn(i / last_index) for i, n in enumerate(arr)]
|