#!/usr/bin/env python3 """Reads Design/tokens.json and writes docs/design/tokens.md and docs/design/tokens-preview.html. Also checks WCAG contrast for every text pairing; exits 1 if a text pair is below 4.5:1.""" import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent tokens = json.loads((ROOT / "Design" / "tokens.json").read_text()) HEX = re.compile(r"^#[0-9A-Fa-f]{6}$") def lum(hexcolor): h = hexcolor.lstrip("#") out = [] for i in (0, 2, 4): c = int(h[i:i + 2], 16) / 255 out.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4) return 0.2126 * out[0] + 0.7152 * out[1] + 0.0722 * out[2] def contrast(a, b): la, lb = sorted((lum(a), lum(b)), reverse=True) return (la + 0.05) / (lb + 0.05) # --- validation ------------------------------------------------------------- errors = [] for name, t in tokens["color"].items(): for theme in ("light", "dark"): if not HEX.match(t.get(theme, "")): errors.append(f"color.{name}.{theme} is not a #RRGGBB value") if errors: print("\n".join(errors)) sys.exit(1) # --- contrast --------------------------------------------------------------- TEXT_PAIRS = [ ("ink.primary", "surface.chassis"), ("ink.primary", "surface.panel"), ("ink.primary", "surface.paper"), ("ink.primary", "surface.well"), ("ink.primary", "surface.selection"), ("ink.onMeter", "surface.meterFace"), ("ink.primary", "surface.keyHigh"), ("ink.primary", "surface.keyLow"), ("ink.secondary", "surface.chassis"), ("ink.secondary", "surface.panel"), ("ink.secondary", "surface.paper"), ("ink.tertiary", "surface.chassis"), ("ink.tertiary", "surface.panel"), ("ink.tertiary", "surface.paper"), ("ink.tertiary", "surface.metalHigh"), ("ink.tertiary", "surface.metalLow"), ("ink.inverse", "accent.record"), ("level.amber", "surface.display"), ] rows, failed = [], False for fg, bg in TEXT_PAIRS: for theme in ("light", "dark"): r = contrast(tokens["color"][fg][theme], tokens["color"][bg][theme]) ok = r >= 4.5 failed |= not ok rows.append((fg, bg, theme, r, ok)) print(f"{'text':16} {'on':20} {'finish':6} ratio") for fg, bg, theme, r, ok in rows: print(f"{fg:16} {bg:20} {theme:6} {r:5.2f} {'ok' if ok else 'FAIL'}") # --- swift ------------------------------------------------------------------ def camel(name): parts = name.replace("-", ".").split(".") return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:]) def num(v): return repr(float(v)) if isinstance(v, float) else str(v) def doc(use): return " /// " + use.replace("\n", " ") def swift_source(t): o = [] add = o.append add("// GENERATED by Design/build_tokens.py from Design/tokens.json. Do not edit by hand.") add("import SwiftUI\n") add("public enum Palette {") for n, c in t["color"].items(): add(doc(c["use"])) add(f" public static let {camel(n)} = Color.dynamic(light: 0x{c['light'][1:].upper()}, dark: 0x{c['dark'][1:].upper()})") add("}\n") add("public enum Opacity {") for n, v in t["opacity"].items(): add(doc(v["use"])) add(f" public static let {camel(n)}: Double = {num(v['value'])}") add("}\n") add("public enum FontFamily: String {") for n, f in t["font"].items(): add(f" case {n.replace('family.', '')} = \"{f['value']}\"") add("}\n") add("public enum Typography {") for n, s in t["type"].items(): add(doc(s["use"])) add(f" public static let {camel(n)} = TextStyle(family: .{s['family']}, size: {num(s['size'])}, weight: {s['weight']}, " f"tracking: {num(s['tracking'])}, line: {num(s['line'])}, caps: {'true' if s['caps'] else 'false'}, " f"tabular: {'true' if s.get('tabular') else 'false'})") add("}\n") add("public enum Segment {") for n, v in t["segment"].items(): add(doc(v["use"])) add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}") add("}\n") add("public enum Space {") for n, v in t["space"].items(): add(f" public static let s{n}: CGFloat = {v}") add("}\n") for enum, group in (("Layout", "layout"), ("Size", "size"), ("Radius", "radius"), ("Border", "border")): add(f"public enum {enum} {{") for n, v in t[group].items(): add(doc(v["use"])) add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}") add("}\n") add("public enum Shadows {") for n, s in t["shadow"].items(): add(doc(s["use"])) add(f" public static let {camel(n)}: [ShadowLayer] = [") for l in s["layers"]: add(f" ShadowLayer(inset: {'true' if l['inset'] else 'false'}, x: {l['x']}, y: {l['y']}, blur: {l['blur']}, spread: {l['spread']}, " f"hex: 0x{l['color'][1:].upper()}, alphaLight: {num(l['alpha']['light'])}, alphaDark: {num(l['alpha']['dark'])}),") add(" ]") if "translateY" in s: add(f" public static let {camel(n)}Offset: CGFloat = {s['translateY']}") add("}\n") add("public enum Texture {") for n, v in t["texture"].items(): add(f" public static let {camel(n)}GrainLight: Double = {num(v['grain']['light'])}") add(f" public static let {camel(n)}GrainDark: Double = {num(v['grain']['dark'])}") add("}\n") add("public enum MeterTokens {") for n, v in t["meter"].items(): val = v["value"] if n == "vu.ticks": add(doc(v["use"])) add(f" public static let vuTicks: [Int] = [{', '.join(str(int(x)) for x in val.split())}]") elif n == "led.segmentSize": w, h = val.split("x") add(doc(v["use"])) add(f" public static let ledSegmentWidth: CGFloat = {w}") add(f" public static let ledSegmentHeight: CGFloat = {h}") elif n == "peak.holdMs": add(doc(v["use"])) add(f" public static let peakHoldSeconds: Double = {num(val / 1000)}") elif n == "led.segments": add(doc(v["use"])) add(f" public static let ledSegments: Int = {val}") else: add(doc(v["use"])) add(f" public static let {camel(n)}: Double = {num(val)}") add("}\n") add("public enum Motion {") for n, m in t["motion"].items(): if n == "reducedMotion": continue add(doc(m["use"])) if m.get("unit") == "ms": add(f" public static let {camel(n)}: Double = {num(m['value'] / 1000)}") elif m.get("unit") in ("s", "ratio", "Hz"): add(f" public static let {camel(n)}: Double = {num(m['value'])}") elif isinstance(m["value"], str) and m["value"].startswith("cubic-bezier"): pts = [x.strip() for x in m["value"][m["value"].index("(") + 1:-1].split(",")] add(f" public static let {camel(n)}: (Double, Double, Double, Double) = ({', '.join(pts)})") add("}") return "\n".join(o) + "\n" swift_text = swift_source(tokens) swift_dir = ROOT / "Sources" / "SstUI" swift_path = swift_dir / "DesignTokens.swift" if "--check" in sys.argv: in_sync = swift_path.exists() and swift_path.read_text() == swift_text print("DesignTokens.swift is " + ("in sync with tokens.json" if in_sync else "OUT OF DATE: run python3 Design/build_tokens.py")) sys.exit(0 if (in_sync and not failed) else 1) if swift_dir.exists(): swift_path.write_text(swift_text) print("wrote Sources/SstUI/DesignTokens.swift") # --- markdown --------------------------------------------------------------- md = [] add = md.append m = tokens["meta"] add(f"# {m['name']} — v{m['version']}\n") add(f"**Status:** {m['status']}\n") add(f"{m['concept']}\n") add("Bron: `Design/tokens.json`. Dit bestand en `tokens-preview.html` worden gegenereerd met `python3 Design/build_tokens.py`; pas nooit alleen deze bestanden aan.\n") def table(headers, body): add("| " + " | ".join(headers) + " |") add("|" + "|".join("---" for _ in headers) + "|") for r in body: add("| " + " | ".join(str(c) for c in r) + " |") add("") add("## Kleur\n") add("Verwijs ernaar als `color.`. Kolommen: ALUMINIUM (licht) en GRAPHITE (donker).\n") table(["Token", "Aluminium", "Graphite", "Gebruik"], [(f"`color.{n}`", f"`{t['light']}`", f"`{t['dark']}`", t["use"]) for n, t in tokens["color"].items()]) add("**Regel:** `accent.record` is het enige accent en wordt nooit voor lopende tekst gebruikt. Amber en groen zijn alleen voor niveau-indicatoren en markeringen.\n") add("### Contrast (berekend)\n") table(["Tekst", "Op", "Afwerking", "Verhouding", ""], [(f"`{a}`", f"`{b}`", th, f"{r:.2f}:1", "ok" if ok else "**te laag**") for a, b, th, r, ok in rows]) add("## Opaciteit\n") table(["Token", "Waarde", "Gebruik"], [(f"`opacity.{n}`", t["value"], t["use"]) for n, t in tokens["opacity"].items()]) add("## Lettertypen\n") table(["Token", "Familie", "Terugval", "Gebruik"], [(f"`font.{n}`", t["value"], t["fallback"], t["use"]) for n, t in tokens["font"].items()]) add("## Tekststijlen\n") table(["Token", "Familie", "Grootte", "Gewicht", "Spatiëring", "Regelhoogte", "Hoofdletters", "Gebruik"], [(f"`type.{n}`", t["family"], f"{t['size']} pt", t["weight"], f"{t['tracking']} em", f"{t['line']} pt", "ja" if t["caps"] else "nee", t["use"]) for n, t in tokens["type"].items()]) add("## Segmentcijfers (getekend, geen lettertype)\n") table(["Token", "Waarde", "Gebruik"], [(f"`segment.{n}`", t["value"], t["use"]) for n, t in tokens["segment"].items()]) add("## Ruimte\n") table(["Token", "Waarde"], [(f"`space.{n}`", f"{v} pt") for n, v in tokens["space"].items()]) add("## Indeling\n") table(["Token", "Waarde", "Gebruik"], [(f"`layout.{n}`", t["value"], t["use"]) for n, t in tokens["layout"].items()]) add("## Afmetingen\n") table(["Token", "Waarde", "Gebruik"], [(f"`size.{n}`", t["value"], t["use"]) for n, t in tokens["size"].items()]) add("## Hoekradius\n") table(["Token", "Waarde", "Gebruik"], [(f"`radius.{n}`", t["value"], t["use"]) for n, t in tokens["radius"].items()]) add("## Randen\n") table(["Token", "Waarde", "Gebruik"], [(f"`border.{n}`", f"{t['value']} pt", t["use"]) for n, t in tokens["border"].items()]) add("## Schaduw en diepte\n") for n, t in tokens["shadow"].items(): add(f"### `shadow.{n}`\n\n{t['use']}" + (f" Dop verschuift {t['translateY']} pt omlaag." if "translateY" in t else "") + "\n") table(["Laag", "Inset", "x", "y", "blur", "spread", "kleur", "alpha aluminium", "alpha graphite"], [(i + 1, "ja" if l["inset"] else "nee", l["x"], l["y"], l["blur"], l["spread"], f"`{l['color']}`", l["alpha"]["light"], l["alpha"]["dark"]) for i, l in enumerate(t["layers"])]) add("## Textuur\n") for n, t in tokens["texture"].items(): add(f"**`texture.{n}`** — {t['use']}. Richting {t['direction']}; korrel {t['grain']['light']} (aluminium) / {t['grain']['dark']} (graphite); {t['gradient']}.\n") add("## Meters\n") table(["Token", "Waarde", "Gebruik"], [(f"`meter.{n}`", t["value"], t["use"]) for n, t in tokens["meter"].items()]) add("## Beweging\n") table(["Token", "Waarde", "Eenheid", "Gebruik"], [(f"`motion.{n}`", t["value"], t.get("unit", ""), t["use"]) for n, t in tokens["motion"].items()]) add("## Wat bewust ontbreekt\n") add("Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten. Lampjes mogen een kleine gloed hebben (`shadow.lamp.on`); tekst nooit.\n") out_dir = ROOT / "docs" / "design" out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "tokens.md").write_text("\n".join(md)) # --- preview ---------------------------------------------------------------- template = (ROOT / "Design" / "preview.template.html").read_text() blob = json.dumps(tokens, ensure_ascii=False).replace("