feat: generate Swift design tokens from tokens.json and add SstUI base
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,138 @@ 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
|
||||
|
||||
@@ -264,29 +264,29 @@ $('#rad').innerHTML = Object.entries(T.radius).map(([n, r]) => `<div><i style="b
|
||||
const VU = T.meter;
|
||||
const vuMin = VU['vu.min'].value, vuMax = VU['vu.max'].value, arc = VU['vu.arcDegrees'].value;
|
||||
const W = VU['vu.width'].value, H = VU['vu.height'].value;
|
||||
const pivot = { x: W / 2, y: H + 22 };
|
||||
const pivot = { x: W / 2, y: H + VU['vu.pivotOffset'].value };
|
||||
const frac = v => Math.max(0, Math.min(1.03, (v - vuMin) / (vuMax - vuMin)));
|
||||
const ang = f => (-arc / 2 + arc * f) * Math.PI / 180;
|
||||
const pt = (r, a) => [pivot.x + r * Math.sin(a), pivot.y - r * Math.cos(a)];
|
||||
const f2 = n => n.toFixed(2);
|
||||
|
||||
function meterSvg() {
|
||||
const R = 142;
|
||||
const R = VU['vu.scaleRadius'].value;
|
||||
let s = `<svg class="vu" viewBox="0 0 ${W} ${H}" role="img" aria-label="VU-meter" style="aspect-ratio:${W}/${H}">`;
|
||||
s += `<rect width="${W}" height="${H}" fill="var(--c-surface-meterFace)"/>`;
|
||||
const redFrom = frac(0);
|
||||
const arcPath = (r0, f0, f1) => { const [x0, y0] = pt(r0, ang(f0)), [x1, y1] = pt(r0, ang(f1)); return `M${f2(x0)} ${f2(y0)} A${r0} ${r0} 0 0 1 ${f2(x1)} ${f2(y1)}`; };
|
||||
s += `<path d="${arcPath(R, 0, redFrom)}" stroke="var(--c-ink-onMeter)" stroke-width="1" fill="none"/>`;
|
||||
s += `<path d="${arcPath(R - 2, redFrom, 1)}" stroke="var(--c-accent-record)" stroke-width="5" fill="none"/>`;
|
||||
s += `<path d="${arcPath(R - 2, redFrom, 1)}" stroke="var(--c-accent-record)" stroke-width="${VU['vu.redBandWidth'].value}" fill="none"/>`;
|
||||
for (let v = vuMin; v <= vuMax; v++) {
|
||||
const major = [-20, -10, -7, -5, -3, 0, 3].includes(v), a = ang(frac(v));
|
||||
const [x0, y0] = pt(R, a), [x1, y1] = pt(R - (major ? 11 : 6), a);
|
||||
const [x0, y0] = pt(R, a), [x1, y1] = pt(R - (major ? VU['vu.tickMajor'].value : VU['vu.tickMinor'].value), a);
|
||||
s += `<line x1="${f2(x0)}" y1="${f2(y0)}" x2="${f2(x1)}" y2="${f2(y1)}" stroke="${v > 0 ? 'var(--c-accent-record)' : 'var(--c-ink-onMeter)'}" stroke-width="${major ? 1.4 : 1}"/>`;
|
||||
if (major) { const [tx, ty] = pt(R - 24, a); s += `<text x="${f2(tx)}" y="${f2(ty + 3)}" text-anchor="middle" font-family="${fam('chrome')}" font-weight="500" font-size="9" letter-spacing=".5" fill="${v > 0 ? 'var(--c-accent-record)' : 'var(--c-ink-onMeter)'}">${v > 0 ? '+' + v : v}</text>`; }
|
||||
if (major) { const [tx, ty] = pt(R - VU['vu.labelInset'].value, a); s += `<text x="${f2(tx)}" y="${f2(ty + 3)}" text-anchor="middle" font-family="${fam('chrome')}" font-weight="500" font-size="9" letter-spacing=".5" fill="${v > 0 ? 'var(--c-accent-record)' : 'var(--c-ink-onMeter)'}">${v > 0 ? '+' + v : v}</text>`; }
|
||||
}
|
||||
s += `<text x="${W / 2}" y="${H - 30}" text-anchor="middle" font-family="${fam('chrome')}" font-weight="500" font-size="15" letter-spacing="3" fill="var(--c-ink-onMeter)">VU</text>`;
|
||||
s += `<text x="14" y="${H - 10}" font-family="${fam('chrome')}" font-weight="500" font-size="8" letter-spacing="1.2" fill="var(--c-ink-onMeter)" opacity=".75">SST · FIELD RECORDER</text>`;
|
||||
s += `<line class="needle" x1="${pivot.x}" y1="${pivot.y}" x2="${pivot.x}" y2="${pivot.y - 146}" stroke="var(--c-ink-onMeter)" stroke-width="${VU['vu.needleWidth'].value}" stroke-linecap="butt"/>`;
|
||||
s += `<line class="needle" x1="${pivot.x}" y1="${pivot.y}" x2="${pivot.x}" y2="${pivot.y - VU['vu.needleLength'].value}" stroke="var(--c-ink-onMeter)" stroke-width="${VU['vu.needleWidth'].value}" stroke-linecap="butt"/>`;
|
||||
s += `<circle cx="${W - 18}" cy="18" r="5" class="peak" fill="var(--c-accent-recordUnlit)" stroke="var(--c-ink-onMeter)" stroke-width=".8"/><text x="${W - 18}" y="34" text-anchor="middle" font-family="${fam('chrome')}" font-size="7" font-weight="500" letter-spacing=".8" fill="var(--c-ink-onMeter)">PEAK</text>`;
|
||||
s += `</svg>`;
|
||||
return s;
|
||||
@@ -304,9 +304,9 @@ function digit(ch, ox, lit) {
|
||||
return `<g transform="translate(${ox},0)">` + Object.entries(P).map(([k, pts]) => `<polygon points="${pts}" fill="${lit && on.includes(k) ? 'var(--c-level-amber)' : 'var(--c-display-segmentUnlit)'}"/>`).join('') + `</g>`;
|
||||
}
|
||||
function counterSvg(text) {
|
||||
const adv = DW + 6; let x = 0, out = '';
|
||||
const adv = SEG.advance.value; let x = 0, out = '';
|
||||
for (const ch of text) {
|
||||
if (ch === ':' || ch === '.') { out += `<rect x="${x + 2}" y="${ch === ':' ? DH * .28 : DH - 5}" width="4" height="4" fill="var(--c-level-amber)"/>` + (ch === ':' ? `<rect x="${x + 2}" y="${DH * .64}" width="4" height="4" fill="var(--c-level-amber)"/>` : ''); x += 10; }
|
||||
if (ch === ':' || ch === '.') { out += `<rect x="${x + 2}" y="${ch === ':' ? DH * .28 : DH - 5}" width="4" height="4" fill="var(--c-level-amber)"/>` + (ch === ':' ? `<rect x="${x + 2}" y="${DH * .64}" width="4" height="4" fill="var(--c-level-amber)"/>` : ''); x += SEG.colonWidth.value; }
|
||||
else { out += digit(ch, x, true); x += adv; }
|
||||
}
|
||||
return `<svg width="${x}" height="${DH}" viewBox="0 0 ${x} ${DH}" role="img" aria-label="Teller ${text}">${out}</svg>`;
|
||||
@@ -350,7 +350,7 @@ function frame(now) {
|
||||
const a = wn * wn * (tgt - r.x) - 2 * zeta * wn * r.v;
|
||||
r.v += a * dt; r.x += r.v * dt;
|
||||
const ang_ = ang(Math.max(-0.02, Math.min(1.05, r.x)));
|
||||
const [nx, ny] = pt(146, ang_);
|
||||
const [nx, ny] = pt(VU['vu.needleLength'].value, ang_);
|
||||
r.needle.setAttribute('x2', f2(nx)); r.needle.setAttribute('y2', f2(ny));
|
||||
const vuNow = vuMin + (vuMax - vuMin) * r.x;
|
||||
if (vuNow > 0 && r.running) r.peakUntil = now + VU['peak.holdMs'].value;
|
||||
|
||||
+15
-2
@@ -45,7 +45,11 @@
|
||||
"opacity": {
|
||||
"mark": { "value": 0.30, "use": "Amber markering achter een gecorrigeerd woord" },
|
||||
"disabled": { "value": 0.45, "use": "Uitgeschakelde bediening" },
|
||||
"unlit": { "value": 1.00, "use": "Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie" }
|
||||
"unlit": { "value": 1.00, "use": "Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie" },
|
||||
"pressedRecord": { "value": 0.85, "use": "Ingedrukte REC-toets: het rood dooft licht" },
|
||||
"specularOn": { "value": 0.60, "use": "Lichtvlekje op een brandend lampje" },
|
||||
"specularOff": { "value": 0.30, "use": "Lichtvlekje op een gedoofd lampje" },
|
||||
"caption": { "value": 0.75, "use": "Kleine opschriften op de wijzerplaat" }
|
||||
},
|
||||
|
||||
"font": {
|
||||
@@ -72,7 +76,9 @@
|
||||
"digitWidth": { "value": 20, "use": "Breedte van een segmentcijfer" },
|
||||
"thickness": { "value": 4, "use": "Dikte van één segment" },
|
||||
"gap": { "value": 1, "use": "Ruimte tussen segmenten" },
|
||||
"slant": { "value": 0, "use": "Schuinte in graden; 0 = rechtop" }
|
||||
"slant": { "value": 0, "use": "Schuinte in graden; 0 = rechtop" },
|
||||
"advance": { "value": 26, "use": "Afstand van cijfer tot cijfer" },
|
||||
"colonWidth": { "value": 10, "use": "Breedte van een dubbele punt of punt" }
|
||||
},
|
||||
|
||||
"space": {
|
||||
@@ -179,6 +185,13 @@
|
||||
"vu.height": { "value": 150, "use": "Hoogte van de meter" },
|
||||
"vu.needleWidth": { "value": 1.5, "use": "Dikte van de wijzer" },
|
||||
"vu.ticks": { "value": "-20 -10 -7 -5 -3 0 +3", "use": "Genummerde schaalmarkeringen" },
|
||||
"vu.pivotOffset": { "value": 22, "use": "Draaipunt van de wijzer, onder de onderrand van de wijzerplaat" },
|
||||
"vu.scaleRadius": { "value": 142, "use": "Straal van de schaalboog vanaf het draaipunt" },
|
||||
"vu.needleLength": { "value": 146, "use": "Lengte van de wijzer" },
|
||||
"vu.tickMajor": { "value": 11, "use": "Lengte van een genummerde streep" },
|
||||
"vu.tickMinor": { "value": 6, "use": "Lengte van een gewone streep" },
|
||||
"vu.labelInset": { "value": 24, "use": "Afstand van de schaalboog tot de cijfers" },
|
||||
"vu.redBandWidth": { "value": 5, "use": "Dikte van de rode band vanaf 0 VU" },
|
||||
"led.segments": { "value": 12, "use": "Aantal segmenten: 8 groen, 3 amber, 1 rood" },
|
||||
"led.segmentSize": { "value": "6x14", "use": "Segment in pt (breedte x hoogte)" },
|
||||
"led.gap": { "value": 2, "use": "Ruimte tussen segmenten" },
|
||||
|
||||
Reference in New Issue
Block a user