#!/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") if kind == "card": lines.append(f" const val {name}Slots = {token['fullSlotsPerRow']}") elif group_name == "image" and isinstance(token.get("px"), list): for suffix, dimension in zip(("Width", "Height"), token["px"]): lines.append(f" const val {name}{suffix} = {int(dimension)}") elif group_name == "image" and isinstance(value, dict): for suffix, count in value.items(): lines.append(f" const val {name}{suffix[0].upper() + suffix[1:]} = {int(count)}") 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 / "title-tokens.json").read_text())) tokens.update(json.loads((ROOT / "home-tokens.json").read_text())) tokens.update(json.loads((ROOT / "discover-tokens.json").read_text())) kotlin, css = render_tokens(tokens) outputs = {str(KOTLIN): kotlin, "tokens.css": css} inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-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()