127 lines
6.5 KiB
Python
Executable file
127 lines
6.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Generate Compose and CSS tokens and a build-verifiable drift manifest."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
KOTLIN = Path("src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt")
|
|
|
|
|
|
def number(value: float) -> str:
|
|
"""Render stable Kotlin decimal literals."""
|
|
return str(float(value))
|
|
|
|
|
|
def property_name(name: str) -> str:
|
|
"""Keep source names valid as Kotlin identifiers."""
|
|
return name.replace("-", "_")
|
|
|
|
|
|
def compose_weight(weight: int) -> str:
|
|
"""Use the platform bold face for intermediate weights that resolve to Regular in native captures."""
|
|
return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})"
|
|
|
|
|
|
def render_tokens(tokens: dict) -> tuple[str, str]:
|
|
"""Translate locked values into platform-resolvable Compose and CSS tokens."""
|
|
lines = [
|
|
"// Generated by generate_tokens.py; edit the JSON sources instead.",
|
|
"package org.justinvisser.wolfskin.design",
|
|
"",
|
|
"import androidx.compose.ui.graphics.Color as ComposeColor",
|
|
"import androidx.compose.ui.text.TextStyle",
|
|
"import androidx.compose.ui.text.font.FontWeight",
|
|
"import androidx.compose.ui.unit.dp",
|
|
"import androidx.compose.ui.unit.em",
|
|
"import androidx.compose.ui.unit.sp",
|
|
"",
|
|
"/** The locked TV design, converted at the source density. */",
|
|
"object WolfskinTokens {",
|
|
]
|
|
css = ["/* Generated from the locked TV token sources. */", ":root {"]
|
|
for group_name, group in tokens.items():
|
|
if group_name.startswith("$") or group_name in ("meta", "font"):
|
|
continue
|
|
lines.append(f" object {group_name[0].upper() + group_name[1:]} {{")
|
|
for name, token in group.items():
|
|
if not isinstance(token, dict):
|
|
continue
|
|
name = property_name(name)
|
|
kind = token.get("$type")
|
|
value = token.get("$value")
|
|
if kind == "color":
|
|
rgba = value.lstrip("#")
|
|
argb = (rgba[6:8] if len(rgba) == 8 else "ff") + rgba[:6]
|
|
lines.append(f" val {name} = ComposeColor(0x{argb.upper()})")
|
|
css.append(f" --{group_name}-{name}: {value};")
|
|
elif kind == "typography" and "sp" in token:
|
|
size = token["sp"]
|
|
weight = token.get("weight", token["$value"]["fontWeight"])
|
|
height = size * token.get("lineHeight", 1.3)
|
|
tracking = token.get("letterSpacingEm", 0)
|
|
lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)")
|
|
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
|
|
elif kind == "typography" and "sizes" in token:
|
|
for context, pixels in token["sizes"].items():
|
|
lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})")
|
|
elif kind == "duration":
|
|
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
|
|
css.append(f" --{group_name}-{name}: {value};")
|
|
elif kind == "transition" and value.get("type") == "spring":
|
|
lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f")
|
|
lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f")
|
|
elif kind == "shadow":
|
|
for suffix in ("gapPx", "ringPx"):
|
|
lines.append(f" val {name}{suffix[0].upper() + suffix[1:-2]} = {number(token[suffix] / 2)}.dp")
|
|
elif isinstance(token.get("dp"), (float, int)):
|
|
lines.append(f" val {name} = {number(token['dp'])}.dp")
|
|
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
|
|
elif isinstance(token.get("dp"), list):
|
|
for suffix, dimension in zip(("Width", "Height"), token["dp"]):
|
|
lines.append(f" val {name}{suffix} = {number(dimension)}.dp")
|
|
elif isinstance(token.get("dp"), dict):
|
|
for suffix, dimension in token["dp"].items():
|
|
lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp")
|
|
elif isinstance(value, (float, int)) and not isinstance(value, bool):
|
|
lines.append(f" const val {name} = {number(value)}f")
|
|
elif kind in ("gradient", "filter", "rule") and isinstance(value, str):
|
|
lines.append(f" const val {name} = {json.dumps(value)}")
|
|
if kind == "gradient":
|
|
css.append(f" --{group_name}-{name}: {value};")
|
|
lines.append(" }")
|
|
lines.append("}")
|
|
css.append("}")
|
|
return "\n".join(lines) + "\n", "\n".join(css) + "\n"
|
|
|
|
|
|
def main() -> None:
|
|
"""Write generated artifacts, or fail when committed artifacts drift."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--check", action="store_true")
|
|
arguments = parser.parse_args()
|
|
tokens = json.loads((ROOT / "tokens.json").read_text())
|
|
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
|
|
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
|
|
kotlin, css = render_tokens(tokens)
|
|
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
|
|
inputs = ["tokens.json", "shell-tokens.json", "home-tokens.json", "generate_tokens.py"]
|
|
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
|
|
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
|
|
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))
|
|
for name, content in outputs.items():
|
|
path = ROOT / name
|
|
if arguments.check:
|
|
if not path.exists() or path.read_text() != content:
|
|
raise SystemExit(f"Token drift: run python3 wolfskin-design/generate_tokens.py ({name})")
|
|
else:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content)
|
|
print("Token drift check passed" if arguments.check else "Generated Kotlin, CSS and drift manifest")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|