"""Evaluate the subset of CSS that Niveus's token layer is written in. Niveus derives its entire color system from one input with relative color syntax, `color-mix()` and `calc()`. None of that survives a copy-paste into another platform's resources (Android XML, a Qt header, GTK CSS), and re-stating the results by hand would fork the system: the CSS would change and the port would quietly keep the old values. So this module implements the language instead, once, here — every port's generator imports it rather than carrying its own copy. It is deliberately narrow — it covers exactly what `niveus/tokens/*.css` and `niveus/themes/*.css` use: #rrggbb / #rgb / #rrggbbaa rgb(r g b / a) transparent oklch(L C H) oklch(from L C H [/ A]) color-mix(in oklab, , ) light-dark(, ) var(--x[, fallback]) calc(...) clamp(min,v,max) min(...) max(...) Inside `oklch(from X ...)` the identifiers `l`, `c`, `h` and `alpha` are bound to X's own OKLCH channels, which is what makes one brand value re-colour everything. Color math follows CSS Color 4: sRGB <-> linear sRGB <-> OKLab <-> OKLCH, and out-of-gamut results are gamut-mapped by chroma reduction against deltaEOK, the same approach browsers take. `color-mix()` mixes premultiplied, in OKLab. """ from __future__ import annotations import math import re from dataclasses import dataclass # --------------------------------------------------------------------------- # Color space conversions (CSS Color 4) # --------------------------------------------------------------------------- def _srgb_to_linear(v: float) -> float: return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 def _linear_to_srgb(v: float) -> float: return v * 12.92 if v <= 0.0031308 else 1.055 * (v ** (1 / 2.4)) - 0.055 # linear sRGB -> LMS -> OKLab, per Björn Ottosson. _LSRGB_TO_LMS = ( (0.4122214708, 0.5363325363, 0.0514459929), (0.2119034982, 0.6806995451, 0.1073969566), (0.0883024619, 0.2817188376, 0.6299787005), ) _LMS_TO_OKLAB = ( (0.2104542553, 0.7936177850, -0.0040720468), (1.9779984951, -2.4285922050, 0.4505937099), (0.0259040371, 0.7827717662, -0.8086757660), ) _OKLAB_TO_LMS = ( (1.0, 0.3963377774, 0.2158037573), (1.0, -0.1055613458, -0.0638541728), (1.0, -0.0894841775, -1.2914855480), ) _LMS_TO_LSRGB = ( (4.0767416621, -3.3077115913, 0.2309699292), (-1.2684380046, 2.6097574011, -0.3413193965), (-0.0041960863, -0.7034186147, 1.7076147010), ) def _mul(m, v): return tuple(sum(r[i] * v[i] for i in range(3)) for r in m) def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]: lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)) lms = _mul(_LSRGB_TO_LMS, lin) lms_ = tuple(math.copysign(abs(x) ** (1 / 3), x) for x in lms) return _mul(_LMS_TO_OKLAB, lms_) def oklab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]: lms_ = _mul(_OKLAB_TO_LMS, (L, a, b)) lms = tuple(x ** 3 for x in lms_) lin = _mul(_LMS_TO_LSRGB, lms) return tuple(_linear_to_srgb(x) for x in lin) def oklab_to_oklch(L: float, a: float, b: float) -> tuple[float, float, float]: c = math.hypot(a, b) # Below this chroma the hue is meaningless; CSS treats it as `none`, and # carrying a garbage angle through a derivation produces garbage hues. h = 0.0 if c < 1e-6 else math.degrees(math.atan2(b, a)) % 360.0 return (L, c, h) def oklch_to_oklab(L: float, c: float, h: float) -> tuple[float, float, float]: rad = math.radians(h) return (L, c * math.cos(rad), c * math.sin(rad)) # --------------------------------------------------------------------------- # CIELAB, for the Android system palette # --------------------------------------------------------------------------- # # Niveus works in OKLab, but Android's tonal palette is defined in terms of # *tone*, which is CIE L* — the same scale Material You's HCT uses for its T # channel. Generating those ramps in OKLab lightness would put every tone at a # slightly wrong luminance, so the palette gets its own, correct, colour space # rather than an approximation. # sRGB (D65) -> XYZ _LRGB_TO_XYZ = ( (0.4123907993, 0.3575843394, 0.1804807884), (0.2126390059, 0.7151686788, 0.0721923154), (0.0193308187, 0.1191947798, 0.9505321522), ) _XYZ_TO_LRGB = ( (3.2409699419, -1.5373831776, -0.4986107603), (-0.9692436363, 1.8759675015, 0.0415550574), (0.0556300797, -0.2039769589, 1.0569715142), ) # D65 white point _WHITE = (0.9504559271, 1.0, 1.0890577508) _EPS = 216 / 24389 _KAPPA = 24389 / 27 def _f(t: float) -> float: return t ** (1 / 3) if t > _EPS else (_KAPPA * t + 16) / 116 def _f_inv(t: float) -> float: t3 = t ** 3 return t3 if t3 > _EPS else (116 * t - 16) / _KAPPA def srgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]: """sRGB to CIE L*a*b*. L* is 0-100 and is what Android calls tone.""" lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)) x, y, z = _mul(_LRGB_TO_XYZ, lin) fx, fy, fz = _f(x / _WHITE[0]), _f(y / _WHITE[1]), _f(z / _WHITE[2]) return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)) def lab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]: fy = (L + 16) / 116 fx = fy + a / 500 fz = fy - b / 200 xyz = (_f_inv(fx) * _WHITE[0], _f_inv(fy) * _WHITE[1], _f_inv(fz) * _WHITE[2]) return tuple(_linear_to_srgb(v) for v in _mul(_XYZ_TO_LRGB, xyz)) def lch_to_srgb_clamped(L: float, C: float, H: float) -> tuple[float, float, float]: """A tone at a given hue and chroma, reduced until it fits sRGB. Tone is fixed — the whole point of a tonal palette is that tone 40 is the same lightness in every ramp — so chroma is what gives way, never L*. """ rad = math.radians(H) lo, hi = 0.0, max(C, 0.0) best = lab_to_srgb(L, 0.0, 0.0) for _ in range(28): mid = (lo + hi) / 2 candidate = lab_to_srgb(L, mid * math.cos(rad), mid * math.sin(rad)) if all(-1e-4 <= v <= 1 + 1e-4 for v in candidate): best = candidate lo = mid else: hi = mid return tuple(min(1.0, max(0.0, v)) for v in best) # --------------------------------------------------------------------------- # Values # --------------------------------------------------------------------------- @dataclass(frozen=True) class Num: value: float unit: str = "" def __repr__(self) -> str: # pragma: no cover - debugging aid return f"Num({self.value}{self.unit})" @dataclass(frozen=True) class Color: """A color held in OKLab plus alpha. `none` means fully transparent.""" L: float a: float b: float alpha: float = 1.0 @staticmethod def from_srgb(r: float, g: float, bl: float, alpha: float = 1.0) -> "Color": L, a, b = srgb_to_oklab(r, g, bl) return Color(L, a, b, alpha) @property def oklch(self) -> tuple[float, float, float]: return oklab_to_oklch(self.L, self.a, self.b) def to_srgb(self) -> tuple[float, float, float]: return oklab_to_srgb(self.L, self.a, self.b) # -- gamut mapping ---------------------------------------------------- def _in_gamut(self, eps: float = 1e-4) -> bool: return all(-eps <= x <= 1 + eps for x in self.to_srgb()) def gamut_mapped(self) -> tuple[float, float, float]: """sRGB triple, chroma-reduced into gamut (CSS Color 4 §13.2).""" if self._in_gamut(): return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb()) L, C, H = self.oklch if L >= 1.0: return (1.0, 1.0, 1.0) if L <= 0.0: return (0.0, 0.0, 0.0) lo, hi = 0.0, C clipped = None for _ in range(32): mid = (lo + hi) / 2 candidate = Color(*oklch_to_oklab(L, mid, H), self.alpha) raw = candidate.to_srgb() if candidate._in_gamut(): lo = mid clipped = tuple(min(1.0, max(0.0, x)) for x in raw) continue clip = tuple(min(1.0, max(0.0, x)) for x in raw) # deltaEOK between the candidate and its clipped form: once the # clip is imperceptible we accept it rather than desaturating more. cl = Color.from_srgb(*clip) if math.dist((candidate.L, candidate.a, candidate.b), (cl.L, cl.a, cl.b)) < 0.02: return clip hi = mid if clipped is not None: return clipped return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb()) # -- output ----------------------------------------------------------- def hex8(self) -> str: """#AARRGGBB — Android's order.""" r, g, b = self.gamut_mapped() a = min(1.0, max(0.0, self.alpha)) return "#{:02X}{:02X}{:02X}{:02X}".format( round(a * 255), round(r * 255), round(g * 255), round(b * 255) ) def hex_rgb(self) -> str: r, g, b = self.gamut_mapped() return "#{:02X}{:02X}{:02X}".format(round(r * 255), round(g * 255), round(b * 255)) def argb_int(self) -> int: r, g, b = self.gamut_mapped() a = min(1.0, max(0.0, self.alpha)) return ( (round(a * 255) << 24) | (round(r * 255) << 16) | (round(g * 255) << 8) | round(b * 255) ) def relative_luminance(self) -> float: r, g, b = self.gamut_mapped() lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)) return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] TRANSPARENT = Color(0.0, 0.0, 0.0, 0.0) def contrast_ratio(fg: Color, bg: Color) -> float: """WCAG 2.1 contrast, compositing `fg` over `bg` if it is translucent.""" if fg.alpha < 1.0: fr, fg_, fb = fg.gamut_mapped() br, bg_, bb = bg.gamut_mapped() a = fg.alpha fg = Color.from_srgb( fr * a + br * (1 - a), fg_ * a + bg_ * (1 - a), fb * a + bb * (1 - a) ) l1, l2 = fg.relative_luminance(), bg.relative_luminance() if l1 < l2: l1, l2 = l2, l1 return (l1 + 0.05) / (l2 + 0.05) # --------------------------------------------------------------------------- # Tokenizer / parser # --------------------------------------------------------------------------- _TOKEN = re.compile( r""" (?P\s+) | (?P\#[0-9a-fA-F]{3,8}) | (?P[a-zA-Z_][-a-zA-Z0-9_]*(?=\()) # A leading `-` starts an identifier only when a digit does not follow, # or `-0.022em` would lex as the identifier `-0`. | (?P-{1,2}(?![0-9])[-a-zA-Z0-9_]+|[a-zA-Z_][-a-zA-Z0-9_]*) | (?P[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?(?:%|[a-zA-Z]+)?) | (?P[(),/*+\-]) """, re.VERBOSE, ) def _lex(s: str) -> list[tuple[str, str]]: out: list[tuple[str, str]] = [] i = 0 while i < len(s): m = _TOKEN.match(s, i) if not m: raise ValueError(f"cannot lex at {s[i:i+30]!r} in {s!r}") i = m.end() kind = m.lastgroup if kind == "ws": continue out.append((kind, m.group())) return out class _Parser: """Recursive descent over the component-value grammar Niveus uses.""" def __init__(self, tokens: list[tuple[str, str]], ev: "Evaluator"): self.t = tokens self.i = 0 self.ev = ev # -- plumbing --------------------------------------------------------- def peek(self) -> tuple[str, str] | None: return self.t[self.i] if self.i < len(self.t) else None def next(self) -> tuple[str, str]: tok = self.t[self.i] self.i += 1 return tok def expect(self, text: str) -> None: tok = self.next() if tok[1] != text: raise ValueError(f"expected {text!r}, got {tok[1]!r}") def at(self, text: str) -> bool: tok = self.peek() return tok is not None and tok[1] == text def args(self) -> list[list[tuple[str, str]]]: """Consume `( a, b, c )` and return each argument's raw tokens.""" self.expect("(") groups: list[list[tuple[str, str]]] = [[]] depth = 0 while True: tok = self.peek() if tok is None: raise ValueError("unterminated (") if tok[1] == "(": depth += 1 elif tok[1] == ")": if depth == 0: self.next() break depth -= 1 elif tok[1] == "," and depth == 0: self.next() groups.append([]) continue groups.append(groups.pop() + [self.next()]) return [g for g in groups if g] # -- arithmetic ------------------------------------------------------- def sum_(self) -> Num: left = self.product() while True: tok = self.peek() if tok is None: break if tok[1] in ("+", "-"): op = self.next()[1] left, right = _same_unit(left, self.product()) unit = left.unit or right.unit left = Num( left.value + right.value if op == "+" else left.value - right.value, unit ) continue # `calc(l -0.045)` lexes the operand as one signed number, so the # sign has to be read back as the operator it was written as. if tok[0] == "num" and tok[1][0] in "+-": left, right = _same_unit(left, self.product()) left = Num(left.value + right.value, left.unit or right.unit) continue break return left def product(self) -> Num: left = self.term() while self.peek() and self.peek()[1] in ("*", "/"): op = self.next()[1] right = self.term() unit = left.unit or right.unit if op == "*": left = Num(left.value * right.value, unit) else: left = Num(left.value / right.value, left.unit) return left def term(self) -> Num: tok = self.peek() if tok is None: raise ValueError("unexpected end of expression") if tok[1] == "(": self.next() v = self.sum_() self.expect(")") return v if tok[1] == "-": self.next() v = self.term() return Num(-v.value, v.unit) if tok[1] == "+": self.next() return self.term() val = self.value() if isinstance(val, Num): return val raise ValueError(f"expected a number, got {val!r}") # -- values ----------------------------------------------------------- def value(self): tok = self.next() kind, text = tok if kind == "hex": return _parse_hex(text) if kind == "num": return _parse_num(text) if kind == "func": name = text.lower() if name == "calc": inner = self.args() return _Parser(inner[0], self.ev).sum_() if name in ("clamp", "min", "max"): vals = [_Parser(g, self.ev).sum_() for g in self.args()] if name == "clamp": lo, v, hi = vals unit = v.unit or lo.unit or hi.unit return Num(min(max(v.value, lo.value), hi.value), unit) pick = min if name == "min" else max chosen = pick(vals, key=lambda n: n.value) return chosen if name == "var": groups = self.args() varname = "".join(t[1] for t in groups[0]) fallback = groups[1] if len(groups) > 1 else None return self.ev.resolve(varname, fallback) if name == "rgb" or name == "rgba": return _parse_rgb(self.args(), self.ev) if name == "oklch": return _parse_oklch(self.args(), self.ev) if name == "oklab": return _parse_oklab(self.args(), self.ev) if name == "color-mix": return _parse_color_mix(self.args(), self.ev) if name == "light-dark": groups = self.args() pick = groups[0] if self.ev.appearance == "light" else groups[1] return _Parser(pick, self.ev).value() raise ValueError(f"unsupported function {name}()") if kind == "ident": low = text.lower() if low == "transparent": return TRANSPARENT if low in ("none", "currentcolor"): return TRANSPARENT if low in _NAMED: return _NAMED[low] if low in self.ev.bindings: return Num(self.ev.bindings[low]) raise ValueError(f"unknown identifier {text!r}") raise ValueError(f"unexpected token {text!r}") # The initial value of the root font size. Tokens never mix units, but # component rules do (`calc(var(--_track-block) - 6px)` with a rem on one # side), and adding the numbers without converting is silently wrong. ROOT_FONT_SIZE_PX = 16.0 def _same_unit(a: Num, b: Num) -> tuple[Num, Num]: """Bring two lengths to a common unit before adding them.""" if a.unit == b.unit or not a.unit or not b.unit: return a, b lengths = {"px": 1.0, "rem": ROOT_FONT_SIZE_PX, "em": ROOT_FONT_SIZE_PX} if a.unit in lengths and b.unit in lengths: return (Num(a.value * lengths[a.unit], "px"), Num(b.value * lengths[b.unit], "px")) raise ValueError(f"cannot add {a.unit} to {b.unit}") _NAMED = { "white": Color.from_srgb(1, 1, 1), "black": Color.from_srgb(0, 0, 0), } def _parse_hex(text: str) -> Color: h = text[1:] if len(h) in (3, 4): h = "".join(ch * 2 for ch in h) if len(h) == 6: r, g, b = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) return Color.from_srgb(r, g, b) if len(h) == 8: r, g, b, a = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4, 6)) return Color.from_srgb(r, g, b, a) raise ValueError(f"bad hex color {text!r}") def _parse_num(text: str) -> Num: m = re.fullmatch(r"([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)(%|[a-zA-Z]*)", text) if not m: raise ValueError(f"bad number {text!r}") v = float(m.group(1)) unit = m.group(2) if unit == "%": return Num(v / 100.0, "%") return Num(v, unit) def _flat(groups: list[list[tuple[str, str]]]) -> list[tuple[str, str]]: out: list[tuple[str, str]] = [] for i, g in enumerate(groups): if i: out.append(("punct", ",")) out.extend(g) return out def _split_slash(tokens: list[tuple[str, str]]) -> tuple[list, list | None]: depth = 0 for i, (_, text) in enumerate(tokens): if text == "(": depth += 1 elif text == ")": depth -= 1 elif text == "/" and depth == 0: return tokens[:i], tokens[i + 1 :] return tokens, None def _components(tokens: list[tuple[str, str]], ev: "Evaluator", n: int) -> list[Num]: """Read `n` space-separated numeric components from a token run.""" p = _Parser(tokens, ev) out: list[Num] = [] for _ in range(n): out.append(p.sum_()) return out def _parse_rgb(groups, ev) -> Color: tokens = _flat(groups) # Both `rgb(r g b / a)` and the legacy `rgb(r, g, b, a)` appear in the wild. body, alpha_tokens = _split_slash(tokens) body = [t for t in body if t[1] != ","] nums = _components(body, ev, 3) vals = [] for n in nums: vals.append(n.value if n.unit == "%" else n.value / 255.0) alpha = 1.0 if alpha_tokens: alpha = _Parser(alpha_tokens, ev).sum_().value return Color.from_srgb(*vals, alpha) def _oklch_channels(tokens, ev, base: Color | None): body, alpha_tokens = _split_slash(tokens) bindings = {} if base is not None: L, C, H = base.oklch bindings = {"l": L, "c": C, "h": H, "alpha": base.alpha} with ev.bound(bindings): nums = _components(body, ev, 3) alpha = base.alpha if base is not None else 1.0 if alpha_tokens: alpha = _Parser(alpha_tokens, ev).sum_().value return nums, alpha def _parse_oklch(groups, ev) -> Color: tokens = _flat(groups) base = None if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from": rest = tokens[1:] p = _Parser(rest, ev) base = p.value() if not isinstance(base, Color): raise ValueError("oklch(from …) needs a color") tokens = rest[p.i :] nums, alpha = _oklch_channels(tokens, ev, base) L, C, H = (n.value for n in nums) # A percentage lightness is 0–1; `oklch(50% …)` and `oklch(0.5 …)` agree. return Color(*oklch_to_oklab(L, C, H), alpha) def _parse_oklab(groups, ev) -> Color: tokens = _flat(groups) base = None if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from": rest = tokens[1:] p = _Parser(rest, ev) base = p.value() tokens = rest[p.i :] body, alpha_tokens = _split_slash(tokens) bindings = {} if base is not None: bindings = {"l": base.L, "a": base.a, "b": base.b, "alpha": base.alpha} with ev.bound(bindings): nums = _components(body, ev, 3) alpha = base.alpha if base is not None else 1.0 if alpha_tokens: alpha = _Parser(alpha_tokens, ev).sum_().value return Color(nums[0].value, nums[1].value, nums[2].value, alpha) def _parse_color_mix(groups, ev) -> Color: # color-mix(in oklab, [pct], [pct]) space_tokens = groups[0] space = " ".join(t[1] for t in space_tokens).replace("in ", "").strip() if space not in ("oklab", "oklch", "srgb"): raise ValueError(f"unsupported color-mix space {space!r}") parsed: list[tuple[Color, float | None]] = [] for g in groups[1:3]: p = _Parser(g, ev) col = p.value() if not isinstance(col, Color): raise ValueError("color-mix needs colors") pct = None if p.peek() is not None: pct = p.sum_().value parsed.append((col, pct)) (ca, pa), (cb, pb) = parsed if pa is None and pb is None: pa = pb = 0.5 elif pa is None: pa = 1.0 - pb elif pb is None: pb = 1.0 - pa total = pa + pb if total == 0: raise ValueError("color-mix percentages sum to zero") pa, pb = pa / total, pb / total # CSS mixes premultiplied by alpha, then un-premultiplies. alpha = ca.alpha * pa + cb.alpha * pb if space == "srgb": ra, ga, ba = ca.gamut_mapped() rb, gb, bb = cb.gamut_mapped() comps = [ (x * ca.alpha * pa + y * cb.alpha * pb) for x, y in ((ra, rb), (ga, gb), (ba, bb)) ] if alpha > 0: comps = [x / alpha for x in comps] return Color.from_srgb(*comps, alpha) if space == "oklch": La, Ca, Ha = ca.oklch Lb, Cb, Hb = cb.oklch # shorter hue arc if abs(Hb - Ha) > 180: if Hb > Ha: Ha += 360 else: Hb += 360 L = La * ca.alpha * pa + Lb * cb.alpha * pb C = Ca * ca.alpha * pa + Cb * cb.alpha * pb H = Ha * ca.alpha * pa + Hb * cb.alpha * pb if alpha > 0: L, C, H = L / alpha, C / alpha, H / alpha return Color(*oklch_to_oklab(L, C, H % 360), alpha) L = ca.L * ca.alpha * pa + cb.L * cb.alpha * pb a = ca.a * ca.alpha * pa + cb.a * cb.alpha * pb b = ca.b * ca.alpha * pa + cb.b * cb.alpha * pb if alpha > 0: L, a, b = L / alpha, a / alpha, b / alpha return Color(L, a, b, alpha) # --------------------------------------------------------------------------- # Evaluator # --------------------------------------------------------------------------- class _Bound: def __init__(self, ev: "Evaluator", extra: dict): self.ev = ev self.extra = extra def __enter__(self): self.saved = self.ev.bindings self.ev.bindings = {**self.ev.bindings, **self.extra} def __exit__(self, *exc): self.ev.bindings = self.saved return False class Evaluator: """Resolves `--nv-*` custom properties for one appearance.""" def __init__(self, declarations: dict[str, str], appearance: str = "light"): self.declarations = declarations self.appearance = appearance self.bindings: dict[str, float] = {} self._cache: dict[tuple[str, str], object] = {} self._stack: list[str] = [] def bound(self, extra: dict) -> _Bound: return _Bound(self, extra) def override(self, name: str, raw: str) -> None: self.declarations[name] = raw self._cache.clear() def resolve(self, name: str, fallback=None): key = (name, self.appearance) if key in self._cache: return self._cache[key] if name not in self.declarations: if fallback is not None: return _Parser(fallback, self).value() raise KeyError(f"undefined custom property {name}") if name in self._stack: raise ValueError(f"cyclic token reference: {' -> '.join(self._stack + [name])}") self._stack.append(name) # Bindings belong to the oklch(from …) that created them, not to a # token resolved from inside it. saved, self.bindings = self.bindings, {} try: value = self.eval(self.declarations[name]) finally: self.bindings = saved self._stack.pop() self._cache[key] = value return value def eval(self, raw: str): return _Parser(_lex(raw.strip()), self).value() def color(self, name: str) -> Color: v = self.resolve(name) if not isinstance(v, Color): raise TypeError(f"{name} is not a color: {v!r}") return v # --------------------------------------------------------------------------- # Reading declarations out of the CSS # --------------------------------------------------------------------------- _COMMENT = re.compile(r"/\*.*?\*/", re.S) _DECL = re.compile(r"(--[-a-zA-Z0-9_]+)\s*:\s*([^;}]+)") def read_declarations(paths, skip_at_rules=True) -> dict[str, str]: """Collect every `--nv-*: value` declaration, last one winning. At-rule bodies (`@media (prefers-reduced-motion)`, `@supports not …`) are skipped: they are alternate appearances of a token, and baking a reduced-motion duration into the default build would be wrong. """ out: dict[str, str] = {} for path in paths: with open(path, encoding="utf-8") as fh: css = _COMMENT.sub("", fh.read()) if skip_at_rules: css = _strip_at_rules(css) for name, value in _DECL.findall(css): out[name] = " ".join(value.split()) return out def _strip_at_rules(css: str) -> str: out = [] i = 0 while i < len(css): at = css.find("@", i) if at == -1: out.append(css[i:]) break out.append(css[i:at]) # Skip to the matching close brace of the at-rule block. brace = css.find("{", at) if brace == -1: break depth = 0 j = brace while j < len(css): if css[j] == "{": depth += 1 elif css[j] == "}": depth -= 1 if depth == 0: break j += 1 i = j + 1 return "".join(out) def read_at_rule_declarations(paths, condition: str) -> dict[str, str]: """Declarations from at-rule blocks whose prelude contains `condition`.""" out: dict[str, str] = {} for path in paths: with open(path, encoding="utf-8") as fh: css = _COMMENT.sub("", fh.read()) for prelude, body in _iter_at_rules(css): if condition in prelude: for name, value in _DECL.findall(body): out[name] = " ".join(value.split()) return out def _iter_at_rules(css: str): i = 0 while True: at = css.find("@", i) if at == -1: return brace = css.find("{", at) if brace == -1: return depth = 0 j = brace while j < len(css): if css[j] == "{": depth += 1 elif css[j] == "}": depth -= 1 if depth == 0: break j += 1 yield css[at:brace], css[brace + 1 : j] i = j + 1 # --------------------------------------------------------------------------- # Reading component rules # --------------------------------------------------------------------------- # # The token layer is custom properties on :root, which read_declarations # covers. A port that draws controls natively (a Qt style, Compose) also needs # the component layer: which role a secondary button's background is, which # shadow it gains on hover, what its disabled border becomes. Those live in # ordinary rules in components/*.css, and restating them in a port would fork # the system just as surely as restating a colour. So they are read here too. def _split_selectors(prelude: str) -> list[str]: """`a, b:not(c, d)` -> [`a`, `b:not(c, d)`], whitespace collapsed.""" out, depth, cur = [], 0, [] for ch in prelude: if ch in "([": depth += 1 elif ch in ")]": depth -= 1 if ch == "," and depth == 0: out.append(" ".join("".join(cur).split())) cur = [] continue cur.append(ch) tail = " ".join("".join(cur).split()) if tail: out.append(tail) return out def _parse_block_declarations(body: str) -> dict[str, str]: out: dict[str, str] = {} depth, cur = 0, [] for ch in body + ";": if ch == "(": depth += 1 elif ch == ")": depth -= 1 if ch == ";" and depth == 0: decl = "".join(cur).strip() cur = [] if ":" in decl: name, value = decl.split(":", 1) out[name.strip()] = " ".join(value.split()) continue cur.append(ch) return out def read_rules(paths, media: tuple[str, ...] = ("hover: hover",)) -> dict[str, dict[str, str]]: """Every style rule, as selector -> {property: value}, last one winning. A grouped selector (`a, b { … }`) is recorded under each of its members. Rules inside `@media` blocks whose condition contains one of `media` are read as if they were top level: `(hover: hover)` only says the device has a pointer, which a desktop always does. Every other at-rule is skipped; those are alternate states (reduced motion, missing features, entry animations) rather than the component's normal appearance. """ rules: dict[str, dict[str, str]] = {} def walk(css: str) -> None: i = 0 while True: brace = css.find("{", i) if brace == -1: return prelude = css[i:brace].strip() depth, j = 0, brace while j < len(css): if css[j] == "{": depth += 1 elif css[j] == "}": depth -= 1 if depth == 0: break j += 1 body = css[brace + 1 : j] if prelude.startswith("@"): if prelude.startswith("@media") and any(m in prelude for m in media): walk(body) else: decls = _parse_block_declarations(body) for sel in _split_selectors(prelude): rules.setdefault(sel, {}).update(decls) i = j + 1 for path in paths: with open(path, encoding="utf-8") as fh: walk(_COMMENT.sub("", fh.read())) return rules