Niveus: a tactile, adaptive interface language

Bevelled controls with light falling across them, fields as wells, solid
windows, and glass kept for the chrome that floats over everything else.
Warm graphite in dark, soft paper in light, one brand color driving both.
Ships its own fonts (Instrument Sans, JetBrains Mono; OFL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-03 17:47:03 +02:00
commit 23b8dd376d
51 changed files with 15664 additions and 0 deletions

113
tools/build.py Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Bundle the Niveus sources into a single stylesheet.
`niveus/niveus.css` is the authored entry point and works as-is over HTTP: the
browser resolves the @import chain itself. That costs a request per file and,
more importantly, blocks rendering until the whole chain has loaded — so for
anything shipped, this script flattens the chain into `dist/niveus.css`.
Each `@import url("…") layer(name)` becomes a `@layer name { … }` block holding
the file's contents, which is exactly equivalent to what the browser does with
the import. Nothing is minified and nothing is reordered; the output is meant
to stay readable and to diff cleanly against the sources.
python3 tools/build.py [--check]
--check verifies dist/ is current without writing, for use in CI.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENTRY = ROOT / "niveus" / "niveus.css"
OUTPUT = ROOT / "dist" / "niveus.css"
IMPORT = re.compile(
r"""@import\s+url\(\s*["']([^"']+)["']\s*\)\s*(?:layer\(\s*([^)]+?)\s*\))?\s*;""",
re.VERBOSE,
)
def indent(text: str, prefix: str = " ") -> str:
return "\n".join(prefix + line if line.strip() else line for line in text.split("\n"))
def bundle(path: Path, seen: set[Path] | None = None) -> str:
"""Inline every @import in `path`, depth first."""
seen = seen if seen is not None else set()
resolved = path.resolve()
if resolved in seen:
raise SystemExit(f"circular @import involving {resolved.relative_to(ROOT)}")
seen.add(resolved)
source = path.read_text(encoding="utf-8")
out: list[str] = []
cursor = 0
for match in IMPORT.finditer(source):
out.append(source[cursor : match.start()])
cursor = match.end()
target = (path.parent / match.group(1)).resolve()
if not target.is_file():
raise SystemExit(f"{path.relative_to(ROOT)}: cannot resolve {match.group(1)}")
# The import's own comment header is kept; it explains the section.
contents = bundle(target, seen).strip("\n")
layer = match.group(2)
if layer:
out.append(f"@layer {layer} {{\n{indent(contents)}\n}}")
else:
out.append(contents)
out.append(source[cursor:])
seen.discard(resolved)
return "".join(out)
def build() -> str:
header = (
"/* Niveus — generated bundle. Do not edit.\n"
" Source: niveus/niveus.css and the files it imports.\n"
" Rebuild: python3 tools/build.py\n"
"*/\n\n"
)
body = bundle(ENTRY)
# Collapse the runs of blank lines left behind by the inlined imports.
body = re.sub(r"\n{3,}", "\n\n", body).strip() + "\n"
return header + body
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="fail if dist/niveus.css differs from the sources",
)
args = parser.parse_args()
result = build()
if args.check:
current = OUTPUT.read_text(encoding="utf-8") if OUTPUT.is_file() else None
if current != result:
print("dist/niveus.css is out of date — run: python3 tools/build.py")
return 1
print("dist/niveus.css is up to date")
return 0
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(result, encoding="utf-8")
size = len(result.encode("utf-8"))
print(f"wrote {OUTPUT.relative_to(ROOT)} ({size:,} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())

255
tools/check.py Executable file
View file

@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Check that the Niveus sources hold to their own rules.
Four things go wrong in a design system long before anything looks broken, and
all four are mechanical:
1. A semantic token is defined for one appearance but not the other, so a
role silently resolves to nothing in light or in dark.
2. A component references a token that does not exist — a typo that degrades
to "no style" instead of to an error.
3. A visual value is hard-coded in a component — or in an example, which
claims to be built from the system and nothing else — instead of coming
from a token, which is how a system stops being a single source of truth.
4. dist/ drifts from the sources.
This script catches the first three; `build.py --check` covers the fourth.
python3 tools/check.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "niveus"
DEFINITION = re.compile(r"(--[a-zA-Z0-9_-]+)\s*:")
REFERENCE = re.compile(r"var\(\s*(--[a-zA-Z0-9_-]+)")
COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
# A raw color or a raw shadow geometry in a component means the design language
# was restated locally instead of referenced.
HARD_CODED = [
(re.compile(r"#[0-9a-fA-F]{3,8}\b"), "hard-coded color"),
(re.compile(r"\b(?:rgb|rgba|hsl|hsla|oklch|lab)\(", re.IGNORECASE), "hard-coded color"),
]
# Properties the *application* supplies per instance, not the system. A menu
# and its trigger have to name the same anchor, and only the markup knows which
# pair belongs together.
CONSUMER_DEFINED = {"--nv-anchor"}
TOKEN_FILES = sorted((SRC / "tokens").glob("*.css")) + sorted((SRC / "themes").glob("*.css"))
CONSUMER_FILES = (
sorted((SRC / "components").glob("*.css"))
+ [SRC / "base.css", SRC / "utilities.css"]
)
def strip_comments(text: str) -> str:
return COMMENT.sub("", text)
def collect(paths: list[Path]) -> tuple[set[str], dict[str, list[tuple[Path, int, str]]]]:
"""Return every custom property defined across `paths`, and every reference."""
defined: set[str] = set()
references: dict[str, list[tuple[Path, int, str]]] = {}
for path in paths:
source = strip_comments(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(source.split("\n"), start=1):
for name in DEFINITION.findall(line):
defined.add(name)
for name in REFERENCE.findall(line):
references.setdefault(name, []).append((path, line_no, line.strip()))
return defined, references
def rel(path: Path) -> str:
return str(path.relative_to(ROOT))
def check_theme_parity(failures: list[str]) -> None:
light = SRC / "themes" / "light.css"
dark = SRC / "themes" / "dark.css"
light_roles = {
name[len("--nv-light-") :]
for name in DEFINITION.findall(strip_comments(light.read_text(encoding="utf-8")))
if name.startswith("--nv-light-")
}
dark_roles = {
name[len("--nv-dark-") :]
for name in DEFINITION.findall(strip_comments(dark.read_text(encoding="utf-8")))
if name.startswith("--nv-dark-")
}
for role in sorted(light_roles - dark_roles):
failures.append(f"themes/dark.css: missing --nv-dark-{role} (light defines it)")
for role in sorted(dark_roles - light_roles):
failures.append(f"themes/light.css: missing --nv-light-{role} (dark defines it)")
if not failures:
print(f" theme parity: {len(light_roles)} roles defined in both appearances")
def check_references(failures: list[str]) -> None:
all_files = TOKEN_FILES + CONSUMER_FILES
defined, references = collect(all_files)
unresolved = []
for name, sites in sorted(references.items()):
if name.startswith("--_"):
continue # component-private, defined and used in the same rule
if name in CONSUMER_DEFINED:
continue
if name in defined:
continue
# var() with a fallback is an intentional escape hatch.
if all("," in line.split(f"var({name}")[1][:40] for _, _, line in sites):
continue
unresolved.append((name, sites[0]))
for name, (path, line_no, _) in unresolved:
failures.append(f"{rel(path)}:{line_no}: {name} is referenced but never defined")
if not unresolved:
print(f" references: {len(references)} tokens referenced, all resolve")
def check_hard_coded(failures: list[str]) -> None:
found = 0
for path in CONSUMER_FILES:
source = strip_comments(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(source.split("\n"), start=1):
for pattern, label in HARD_CODED:
if pattern.search(line):
failures.append(f"{rel(path)}:{line_no}: {label} — {line.strip()}")
found += 1
if not found:
print(f" no hard-coded colors in {len(CONSUMER_FILES)} component and base files")
REFERENCE_DOC = ROOT / "REFERENCE.md"
EXAMPLES = ROOT / "examples"
# The one literal color an example is allowed: the input to the system.
BRAND_INPUT = re.compile(r"--nv-brand\s*:")
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE)
INLINE_STYLE = re.compile(r"""\sstyle=["']([^"']*)["']""")
def check_examples(failures: list[str]) -> None:
"""The examples claim to be built from the system and nothing else.
That claim is only worth making if it is enforced: an example that quietly
reaches for a hex value is no longer demonstrating the system, it is
demonstrating how to work around one. The single exception is --nv-brand,
which is the input the whole color system derives from — a literal there is
the point.
"""
if not EXAMPLES.is_dir():
return
scanned = 0
for path in sorted(EXAMPLES.rglob("*")):
if path.suffix not in {".css", ".html"}:
continue
scanned += 1
raw = path.read_text(encoding="utf-8")
if path.suffix == ".css":
regions = [(1, strip_comments(raw))]
else:
# Only the parts of an HTML file that can carry style.
regions = []
for match in STYLE_BLOCK.finditer(raw):
line = raw[: match.start()].count("\n") + 1
regions.append((line, strip_comments(match.group(1))))
for match in INLINE_STYLE.finditer(raw):
line = raw[: match.start()].count("\n") + 1
regions.append((line, match.group(1)))
for base_line, region in regions:
for offset, line in enumerate(region.split("\n")):
if BRAND_INPUT.search(line):
continue
for pattern, label in HARD_CODED:
if pattern.search(line):
failures.append(
f"{rel(path)}:{base_line + offset}: {label} in an example "
f"— {line.strip()[:70]}"
)
if path.name == "index.html" and "dist/niveus.css" not in raw:
failures.append(f"{rel(path)}: does not link the system stylesheet")
if not failures:
print(f" examples: {scanned} files, no color stated outside --nv-brand")
def check_reference(failures: list[str]) -> None:
"""Every class the CSS defines must appear in REFERENCE.md, spelled in full.
The reference claims to be the entire API — which is the only reason it is
safe to hand to someone, or to a model, instead of the source. A class that
exists but is undocumented breaks that claim silently, and a documented
class that no longer exists is worse: it reads as real.
"""
if not REFERENCE_DOC.is_file():
return
documented = REFERENCE_DOC.read_text(encoding="utf-8")
defined: set[str] = set()
sources = (
sorted((SRC / "components").glob("*.css"))
+ [SRC / "utilities.css", SRC / "base.css", SRC / "continuity.css"]
)
for path in sources:
source = strip_comments(path.read_text(encoding="utf-8"))
defined.update(re.findall(r"\.(nv-[a-z0-9_-]+)", source))
missing = sorted(name for name in defined if f".{name}" not in documented)
for name in missing:
failures.append(f"REFERENCE.md: .{name} is defined in the CSS but not documented")
# And the reverse: nothing documented that no longer exists.
mentioned = set(re.findall(r"`\.(nv-[a-z0-9_-]+)`", documented))
for name in sorted(mentioned - defined):
failures.append(f"REFERENCE.md: .{name} is documented but not defined anywhere")
if not missing:
print(f" reference: {len(defined)} classes, all documented")
def main() -> int:
failures: list[str] = []
print("Niveus checks")
check_theme_parity(failures)
check_references(failures)
check_hard_coded(failures)
check_examples(failures)
check_reference(failures)
if failures:
print(f"\n{len(failures)} problem(s):")
for failure in failures:
print(f" {failure}")
return 1
print("\nAll checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())

944
tools/niveus_css.py Normal file
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