CSS Gradient Generator — Python source
Build linear, radial, and conic CSS gradients with multiple color stops and positions. Live preview and copy-ready CSS.
This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.
"""Pure CSS-gradient builder — Python polyglot showcase port.
Language: Python 3 (standard library only)
Origin: CosmoDev polyglot showcase — port of the css-gradient-generator tool
Ported from: src/lib/cssGradient.ts (the canonical TypeScript implementation)
Purpose: Build linear / radial / conic CSS gradient strings from a small
config. Deterministic and side-effect free — never raises.
Display source — part of CosmoDev's polyglot tool pages.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from typing import Literal, Optional
GradientType = Literal["linear", "radial", "conic"]
# Named CSS colors this tool accepts. The full spec defines ~148, but we accept
# only the common, unambiguous set so output stays predictable (mirrors the
# TypeScript allow-list).
_NAMED_COLORS = frozenset({
"transparent", "black", "white", "red", "green", "blue", "yellow",
"orange", "purple", "pink", "gray", "grey", "brown", "cyan", "magenta",
"none", "currentcolor",
})
# Regexes mirror the TypeScript source exactly.
_HEX_3_OR_6 = re.compile(r"^#[0-9a-f]{3}([0-9a-f]{3})?$")
_HEX_8 = re.compile(r"^#[0-9a-f]{8}$")
_RGB_FUNC = re.compile(r"^rgba?\([^)]+\)$")
_HSL_FUNC = re.compile(r"^hsla?\([^)]+\)$")
@dataclass
class GradientStop:
"""One color anchor on the gradient ramp. Position is a percentage 0..100."""
color: str
position: float
@dataclass
class GradientConfig:
"""Full input to build_gradient.
`radial_shape` is only meaningful for type "radial"; None falls back to
"circle" (mirroring TS `?? 'circle'` — an explicit empty string passes
through unchanged).
"""
type: GradientType
angle: float
stops: list[GradientStop]
radial_shape: Optional[str] = None
@dataclass
class ColorResult:
"""Outcome of parse_color: an ok flag plus a human message (None when ok)."""
ok: bool
error: Optional[str] = None
def parse_color(color: str) -> ColorResult:
"""Validate a CSS color string.
Accepts named colors, #RGB / #RRGGBB / #RRGGBBAA hex, and rgb()/rgba()/
hsl()/hsla() functional forms. The input is trimmed and lowercased first.
"""
c = (color or "").strip().lower()
if not c:
return ColorResult(ok=False, error="empty color")
if c in _NAMED_COLORS:
return ColorResult(ok=True)
if _HEX_3_OR_6.match(c) or _HEX_8.match(c):
return ColorResult(ok=True)
if _RGB_FUNC.match(c) or _HSL_FUNC.match(c):
return ColorResult(ok=True)
return ColorResult(ok=False, error=f"invalid color: {color}")
def _normalize_color(color: str) -> str:
"""Coerce a possibly-invalid color to a safe value: invalid → solid black.
Original casing is preserved — we only trim, matching the TS source.
"""
return color.strip() if parse_color(color).ok else "#000000"
def _round_like_js(x: float) -> int:
"""Round the way JavaScript's Math.round does (half toward +infinity).
Python's built-in round() uses banker's rounding (half to even), which
would disagree with Math.round on .5 values. math.floor(x + 0.5) matches
Math.round for all non-negative inputs — the gradient-position domain.
"""
return math.floor(x + 0.5)
def _format_number(x: float) -> str:
"""Render a float the way JavaScript's template literal does.
JS uses the shortest round-tripping decimal; Python's str/repr for floats
does too (since 3.1). We drop the trailing ".0" Python adds to whole
floats so 90.0 renders as "90", matching String(90).
"""
s = repr(float(x))
return s[:-2] if s.endswith(".0") else s
def build_gradient(config: GradientConfig) -> str:
"""Render a complete CSS gradient string.
Stops are sorted ascending by position (stable — sorted() is stable,
matching modern JavaScript's Array.sort). Fewer than two stops collapse to
a black → white default ramp so the output is always renderable.
"""
stops = sorted(config.stops, key=lambda s: s.position)
if len(stops) < 2:
stops = [
GradientStop(color="#000000", position=0),
GradientStop(color="#ffffff", position=100),
]
stops_str = ", ".join(
f"{_normalize_color(s.color)} {_round_like_js(s.position)}%" for s in stops
)
angle = _format_number(config.angle)
if config.type == "linear":
return f"linear-gradient({angle}deg, {stops_str})"
if config.type == "radial":
# `?? 'circle'`: only None falls back to "circle"; an explicit empty
# string passes through (and would produce a degenerate gradient).
shape = config.radial_shape if config.radial_shape is not None else "circle"
return f"radial-gradient({shape}, {stops_str})"
if config.type == "conic":
return f"conic-gradient(from {angle}deg, {stops_str})"
# Unknown type — mirrors the TypeScript, which falls off the switch and
# implicitly returns None.
return ""
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →