Files
sst/Design/build_tokens.py
T

272 lines
12 KiB
Python

#!/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.<naam>`. 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("</", "<\\/")
(out_dir / "tokens-preview.html").write_text(template.replace("/*TOKENS_JSON*/", blob))
print("wrote docs/design/tokens.md and docs/design/tokens-preview.html")
sys.exit(1 if failed else 0)