SchOS/docs/Website/niveus/tools/niveus_css.py
Nexoniarz a8215149a3 docs/Website: Niveus at upstream f340d56; fonts are binary
The vendored copy of Niveus, the site's design system, brought up to
the current upstream (github.com/Nexoniarz/Niveus, 2026-10-18). The
tree under docs/Website/niveus is upstream's byte for byte.

.gitattributes says `* text eol=lf` and listed the binary types it
knew, and woff2 was not one of them: Niveus's InstrumentSans-Italic
had a CR LF pair in its compressed data, and committing it turned that
into LF and the font into garbage. woff, woff2, otf, ico, gif, webp,
pdf and wasm are binary now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
2026-10-23 15:11:20 +02:00

944 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 <color> L C H [/ A])
color-mix(in oklab, <a>, <b> <pct>)
light-dark(<a>, <b>) 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<ws>\s+)
| (?P<hex>\#[0-9a-fA-F]{3,8})
| (?P<func>[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<ident>-{1,2}(?![0-9])[-a-zA-Z0-9_]+|[a-zA-Z_][-a-zA-Z0-9_]*)
| (?P<num>[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?(?:%|[a-zA-Z]+)?)
| (?P<punct>[(),/*+\-])
""",
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, <a> [pct], <b> [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