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
This commit is contained in:
Nexoniarz 2026-10-23 15:10:40 +02:00
commit a8215149a3
56 changed files with 8139 additions and 279 deletions

View file

@ -0,0 +1,944 @@
"""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