272 lines
12 KiB
Python
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)
|