diff --git a/Design/build_tokens.py b/Design/build_tokens.py
index 8ca01e9..c81edd0 100644
--- a/Design/build_tokens.py
+++ b/Design/build_tokens.py
@@ -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
diff --git a/Design/preview.template.html b/Design/preview.template.html
index 4bac882..7c5ceb6 100644
--- a/Design/preview.template.html
+++ b/Design/preview.template.html
@@ -264,29 +264,29 @@ $('#rad').innerHTML = Object.entries(T.radius).map(([n, r]) => `
`;
s += ``;
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 += ``;
- s += ``;
+ s += ``;
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 += ``;
- if (major) { const [tx, ty] = pt(R - 24, a); s += `${v > 0 ? '+' + v : v}`; }
+ if (major) { const [tx, ty] = pt(R - VU['vu.labelInset'].value, a); s += `${v > 0 ? '+' + v : v}`; }
}
s += `VU`;
s += `SST · FIELD RECORDER`;
- s += ``;
+ s += ``;
s += `PEAK`;
s += ``;
return s;
@@ -304,9 +304,9 @@ function digit(ch, ox, lit) {
return `` + Object.entries(P).map(([k, pts]) => ``).join('') + ``;
}
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 += `` + (ch === ':' ? `` : ''); x += 10; }
+ if (ch === ':' || ch === '.') { out += `` + (ch === ':' ? `` : ''); x += SEG.colonWidth.value; }
else { out += digit(ch, x, true); x += adv; }
}
return ``;
@@ -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;
diff --git a/Design/tokens.json b/Design/tokens.json
index 0563c1d..a8f350f 100644
--- a/Design/tokens.json
+++ b/Design/tokens.json
@@ -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" },
diff --git a/Package.swift b/Package.swift
index 3ef7517..f7272f3 100644
--- a/Package.swift
+++ b/Package.swift
@@ -9,6 +9,7 @@ let package = Package(
],
targets: [
.target(name: "SstCore"),
+ .target(name: "SstUI"),
.target(name: "SstData", dependencies: ["SstCore"]),
.target(
name: "SstWhisper",
@@ -22,6 +23,7 @@ let package = Package(
.testTarget(name: "SstCoreTests", dependencies: ["SstCore"]),
.testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]),
.testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]),
+ .testTarget(name: "SstUITests", dependencies: ["SstUI"]),
],
swiftLanguageModes: [.v5]
)
diff --git a/Sources/SstUI/DesignTokens.swift b/Sources/SstUI/DesignTokens.swift
new file mode 100644
index 0000000..e8a4e1d
--- /dev/null
+++ b/Sources/SstUI/DesignTokens.swift
@@ -0,0 +1,316 @@
+// GENERATED by Design/build_tokens.py from Design/tokens.json. Do not edit by hand.
+import SwiftUI
+
+public enum Palette {
+ /// Venster-achtergrond; het 'huis' van het apparaat
+ public static let surfaceChassis = Color.dynamic(light: 0xCBC8C0, dark: 0x1C1B19)
+ /// Geborsteld metaal, bovenkant van de verloop
+ public static let surfaceMetalHigh = Color.dynamic(light: 0xE3E1DB, dark: 0x3B3A36)
+ /// Geborsteld metaal, onderkant van de verloop
+ public static let surfaceMetalLow = Color.dynamic(light: 0xBDBAB2, dark: 0x262522)
+ /// Frontplaat van mat plastic; zijbalk en bedieningspanelen
+ public static let surfacePanel = Color.dynamic(light: 0xE2DFD5, dark: 0x2B2A27)
+ /// Verzonken vlak: invoervelden, display-vensters
+ public static let surfaceWell = Color.dynamic(light: 0xB4B1A8, dark: 0x131211)
+ /// Cassette-label crème: lijsten met transcripties en woordenboek
+ public static let surfacePaper = Color.dynamic(light: 0xEEE9D9, dark: 0x232220)
+ /// Toetsdop, bovenkant
+ public static let surfaceKeyHigh = Color.dynamic(light: 0xE6E3DA, dark: 0x44433F)
+ /// Toetsdop, onderkant
+ public static let surfaceKeyLow = Color.dynamic(light: 0xCBC8BF, dark: 0x33322F)
+ /// Ingedrukte toets
+ public static let surfaceKeyPressed = Color.dynamic(light: 0xBEBBB2, dark: 0x2A2927)
+ /// Wijzerplaat van de VU-meter (blijft crème in beide afwerkingen)
+ public static let surfaceMeterFace = Color.dynamic(light: 0xEFE6C8, dark: 0xD8CEA6)
+ /// Geselecteerde rij in een lijst
+ public static let surfaceSelection = Color.dynamic(light: 0xD4CDB2, dark: 0x3A3828)
+ /// Displayglas van de teller; donker in beide afwerkingen
+ public static let surfaceDisplay = Color.dynamic(light: 0x1B1A18, dark: 0x0E0D0C)
+ /// Gedoofd segment op het displayglas
+ public static let displaySegmentUnlit = Color.dynamic(light: 0x3A3320, dark: 0x2A2415)
+ /// Hoofdtekst en wijzer
+ public static let inkPrimary = Color.dynamic(light: 0x1E1D1B, dark: 0xECE8DC)
+ /// Secundaire tekst
+ public static let inkSecondary = Color.dynamic(light: 0x403D38, dark: 0xC4BFB2)
+ /// Zeefdruk-labels en hulptekst
+ public static let inkTertiary = Color.dynamic(light: 0x4D4A44, dark: 0xABA79B)
+ /// Uitgeschakeld; nooit voor essentiële tekst
+ public static let inkDisabled = Color.dynamic(light: 0x8F8B82, dark: 0x6B685F)
+ /// Tekst op rode of donkere vulling; crème in beide afwerkingen
+ public static let inkInverse = Color.dynamic(light: 0xF4EFE3, dark: 0xF4EFE3)
+ /// Schaal, cijfers en wijzer op de wijzerplaat; vast donker in beide afwerkingen
+ public static let inkOnMeter = Color.dynamic(light: 0x1E1D1B, dark: 0x1E1D1B)
+ /// ENIGE accent: opnamelampje, REC-toets, piek-LED. Nooit voor lopende tekst
+ public static let accentRecord = Color.dynamic(light: 0xC8281E, dark: 0xCC2A20)
+ /// Lampje uit
+ public static let accentRecordUnlit = Color.dynamic(light: 0x7A2E28, dark: 0x4A1F1B)
+ /// Niveau-segment, brandend
+ public static let levelGreen = Color.dynamic(light: 0x5E9E48, dark: 0x6DB556)
+ /// Niveau-segment, waarschuwing, gecorrigeerd woord
+ public static let levelAmber = Color.dynamic(light: 0xD9A023, dark: 0xE5B032)
+ /// Groen segment, uit
+ public static let levelGreenUnlit = Color.dynamic(light: 0xA8B79C, dark: 0x26331F)
+ /// Amber segment, uit
+ public static let levelAmberUnlit = Color.dynamic(light: 0xCDBA8A, dark: 0x3D3418)
+ /// Naad tussen panelen, donkere helft
+ public static let lineSeamDark = Color.dynamic(light: 0x8C8981, dark: 0x0A0A09)
+ /// Naad tussen panelen, lichte helft
+ public static let lineSeamLight = Color.dynamic(light: 0xF3F1EB, dark: 0x45433F)
+ /// Fijne scheidingslijn, zeefdruk-kader
+ public static let lineHairline = Color.dynamic(light: 0xA9A69D, dark: 0x3A3834)
+ /// Toetsenbord-focus
+ public static let lineFocus = Color.dynamic(light: 0x1E1D1B, dark: 0xECE8DC)
+}
+
+public enum Opacity {
+ /// Amber markering achter een gecorrigeerd woord
+ public static let mark: Double = 0.3
+ /// Uitgeschakelde bediening
+ public static let disabled: Double = 0.45
+ /// Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie
+ public static let unlit: Double = 1.0
+ /// Ingedrukte REC-toets: het rood dooft licht
+ public static let pressedRecord: Double = 0.85
+ /// Lichtvlekje op een brandend lampje
+ public static let specularOn: Double = 0.6
+ /// Lichtvlekje op een gedoofd lampje
+ public static let specularOff: Double = 0.3
+ /// Kleine opschriften op de wijzerplaat
+ public static let caption: Double = 0.75
+}
+
+public enum FontFamily: String {
+ case chrome = "Helvetica Neue"
+ case text = "SF Pro Text"
+ case mono = "SF Mono"
+}
+
+public enum Typography {
+ /// Zeefdruk-label
+ public static let label = TextStyle(family: .chrome, size: 10, weight: 500, tracking: 0.09, line: 12, caps: true, tabular: false)
+ /// Schaalmarkeringen en eenheden
+ public static let labelSmall = TextStyle(family: .chrome, size: 9, weight: 500, tracking: 0.1, line: 11, caps: true, tabular: false)
+ /// Opschrift op toetsen
+ public static let control = TextStyle(family: .chrome, size: 11, weight: 500, tracking: 0.06, line: 14, caps: true, tabular: false)
+ /// Paneelkop
+ public static let heading = TextStyle(family: .chrome, size: 12, weight: 500, tracking: 0.12, line: 16, caps: true, tabular: false)
+ /// Standaardtekst
+ public static let body = TextStyle(family: .text, size: 13, weight: 400, tracking: 0, line: 18, caps: false, tabular: false)
+ /// Transcript
+ public static let reading = TextStyle(family: .text, size: 15, weight: 400, tracking: 0, line: 22, caps: false, tabular: false)
+ /// Bijschriften
+ public static let meta = TextStyle(family: .text, size: 11, weight: 400, tracking: 0, line: 14, caps: false, tabular: false)
+ /// Teller
+ public static let counter = TextStyle(family: .mono, size: 22, weight: 500, tracking: 0, line: 26, caps: false, tabular: true)
+ /// Tijdstip in een rij
+ public static let counterSmall = TextStyle(family: .mono, size: 12, weight: 500, tracking: 0, line: 16, caps: false, tabular: true)
+ /// Woordenboekregels
+ public static let code = TextStyle(family: .mono, size: 12, weight: 400, tracking: 0, line: 18, caps: false, tabular: false)
+}
+
+public enum Segment {
+ /// Hoogte van een getekend segmentcijfer
+ public static let digitHeight: CGFloat = 36
+ /// Breedte van een segmentcijfer
+ public static let digitWidth: CGFloat = 20
+ /// Dikte van één segment
+ public static let thickness: CGFloat = 4
+ /// Ruimte tussen segmenten
+ public static let gap: CGFloat = 1
+ /// Schuinte in graden; 0 = rechtop
+ public static let slant: CGFloat = 0
+ /// Afstand van cijfer tot cijfer
+ public static let advance: CGFloat = 26
+ /// Breedte van een dubbele punt of punt
+ public static let colonWidth: CGFloat = 10
+}
+
+public enum Space {
+ public static let s0: CGFloat = 0
+ public static let s1: CGFloat = 2
+ public static let s2: CGFloat = 4
+ public static let s3: CGFloat = 8
+ public static let s4: CGFloat = 12
+ public static let s5: CGFloat = 16
+ public static let s6: CGFloat = 24
+ public static let s7: CGFloat = 32
+ public static let s8: CGFloat = 48
+}
+
+public enum Layout {
+ /// Minimale vensterbreedte
+ public static let windowMinWidth: CGFloat = 800
+ /// Minimale vensterhoogte
+ public static let windowMinHeight: CGFloat = 560
+ /// Standaard vensterbreedte
+ public static let windowDefaultWidth: CGFloat = 1080
+ /// Standaard vensterhoogte
+ public static let windowDefaultHeight: CGFloat = 700
+ /// Breedte zijbalk
+ public static let sidebarWidth: CGFloat = 232
+ /// Breedte instellingenvenster
+ public static let settingsWidth: CGFloat = 560
+ /// Binnenmarge van een paneel (= space.5)
+ public static let panelPadding: CGFloat = 16
+ /// Afstand tussen panelen (= space.4)
+ public static let panelGap: CGFloat = 12
+}
+
+public enum Size {
+ /// Standaardhoogte van toets of veld
+ public static let controlHeight: CGFloat = 28
+ /// Minimale breedte van een toets
+ public static let keyMinWidth: CGFloat = 56
+ /// REC-toets, breedte
+ public static let keyRecWidth: CGFloat = 64
+ /// REC-toets, hoogte
+ public static let keyRecHeight: CGFloat = 44
+ /// Standaard lampje
+ public static let lampDiameter: CGFloat = 10
+ /// Opnamelampje
+ public static let lampLarge: CGFloat = 14
+ /// Tuimelschakelaar, breedte
+ public static let switchWidth: CGFloat = 40
+ /// Tuimelschakelaar, hoogte
+ public static let switchHeight: CGFloat = 22
+ /// Rij in transcriptielijst
+ public static let rowHeight: CGFloat = 40
+ /// Klein pictogram
+ public static let iconSmall: CGFloat = 16
+ /// Normaal pictogram
+ public static let iconMedium: CGFloat = 20
+ /// Minimaal klikdoel
+ public static let hitMin: CGFloat = 24
+}
+
+public enum Radius {
+ /// Panelen en velden: scherpe randen
+ public static let none: CGFloat = 0
+ /// Toetsranden: bijna scherp
+ public static let sharp: CGFloat = 1
+ /// Toetsen, velden
+ public static let sm: CGFloat = 2
+ /// Grote panelen
+ public static let md: CGFloat = 4
+ /// Rand van de VU-meter
+ public static let bezel: CGFloat = 6
+ /// Lampjes: rond
+ public static let lamp: CGFloat = 999
+}
+
+public enum Border {
+ /// Fijne lijn
+ public static let hairline: CGFloat = 1
+ /// Naad: 1 donkere lijn boven, 1 lichte lijn eronder, leest als gegraveerde groef
+ public static let seam: CGFloat = 2
+ /// Rand om de VU-meter
+ public static let bezel: CGFloat = 3
+ /// Focusring, 2 pt offset
+ public static let focus: CGFloat = 2
+}
+
+public enum Shadows {
+ /// Toets in rust: lichtvang boven, donkere onderrand, valschaduw
+ public static let keyRaised: [ShadowLayer] = [
+ ShadowLayer(inset: true, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.65, alphaDark: 0.1),
+ ShadowLayer(inset: true, x: 0, y: -1, blur: 0, spread: 0, hex: 0x000000, alphaLight: 0.25, alphaDark: 0.55),
+ ShadowLayer(inset: false, x: 0, y: 2, blur: 2, spread: 0, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.6),
+ ]
+ /// Ingedrukte toets: dop 1 pt omlaag, geen valschaduw
+ public static let keyPressed: [ShadowLayer] = [
+ ShadowLayer(inset: true, x: 0, y: 2, blur: 4, spread: 0, hex: 0x000000, alphaLight: 0.5, alphaDark: 0.7),
+ ShadowLayer(inset: true, x: 0, y: 0, blur: 0, spread: 1, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.6),
+ ]
+ public static let keyPressedOffset: CGFloat = 1
+ /// Verzonken vlak met lichtvang op de onderlip
+ public static let well: [ShadowLayer] = [
+ ShadowLayer(inset: true, x: 0, y: 1, blur: 3, spread: 0, hex: 0x000000, alphaLight: 0.4, alphaDark: 0.7),
+ ShadowLayer(inset: false, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.55, alphaDark: 0.07),
+ ]
+ /// Frontplaat boven het chassis
+ public static let panel: [ShadowLayer] = [
+ ShadowLayer(inset: true, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.6, alphaDark: 0.08),
+ ShadowLayer(inset: false, x: 0, y: 3, blur: 8, spread: 0, hex: 0x000000, alphaLight: 0.22, alphaDark: 0.55),
+ ]
+ /// Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst
+ public static let lampOn: [ShadowLayer] = [
+ ShadowLayer(inset: false, x: 0, y: 0, blur: 6, spread: 1, hex: 0xCC2A20, alphaLight: 0.45, alphaDark: 0.55),
+ ShadowLayer(inset: true, x: 0, y: -1, blur: 1, spread: 0, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.45),
+ ]
+}
+
+public enum Texture {
+ public static let brushedGrainLight: Double = 0.035
+ public static let brushedGrainDark: Double = 0.05
+}
+
+public enum MeterTokens {
+ /// 0 VU = -20 dBFS (RMS)
+ public static let vuReferenceDbfs: Double = -20
+ /// Onderkant van de schaal in VU
+ public static let vuMin: Double = -20
+ /// Bovenkant van de schaal in VU; vanaf 0 VU rood
+ public static let vuMax: Double = 3
+ /// Hoek tussen minimale en maximale stand
+ public static let vuArcDegrees: Double = 90
+ /// Breedte van de meter
+ public static let vuWidth: Double = 260
+ /// Hoogte van de meter
+ public static let vuHeight: Double = 150
+ /// Dikte van de wijzer
+ public static let vuNeedleWidth: Double = 1.5
+ /// Genummerde schaalmarkeringen
+ public static let vuTicks: [Int] = [-20, -10, -7, -5, -3, 0, 3]
+ /// Draaipunt van de wijzer, onder de onderrand van de wijzerplaat
+ public static let vuPivotOffset: Double = 22
+ /// Straal van de schaalboog vanaf het draaipunt
+ public static let vuScaleRadius: Double = 142
+ /// Lengte van de wijzer
+ public static let vuNeedleLength: Double = 146
+ /// Lengte van een genummerde streep
+ public static let vuTickMajor: Double = 11
+ /// Lengte van een gewone streep
+ public static let vuTickMinor: Double = 6
+ /// Afstand van de schaalboog tot de cijfers
+ public static let vuLabelInset: Double = 24
+ /// Dikte van de rode band vanaf 0 VU
+ public static let vuRedBandWidth: Double = 5
+ /// Aantal segmenten: 8 groen, 3 amber, 1 rood
+ public static let ledSegments: Int = 12
+ /// Segment in pt (breedte x hoogte)
+ public static let ledSegmentWidth: CGFloat = 6
+ public static let ledSegmentHeight: CGFloat = 14
+ /// Ruimte tussen segmenten
+ public static let ledGap: Double = 2
+ /// Piek-LED blijft zo lang branden
+ public static let peakHoldSeconds: Double = 1.0
+}
+
+public enum Motion {
+ /// Geen overgang
+ public static let durationInstant: Double = 0.0
+ /// Toets in/uit
+ public static let durationFast: Double = 0.06
+ /// Standaardovergang
+ public static let durationBase: Double = 0.12
+ /// Paneel of venster
+ public static let durationSlow: Double = 0.22
+ /// Toetsen: direct, zonder veer
+ public static let easingMechanical: (Double, Double, Double, Double) = (0.2, 0, 0, 1)
+ /// Overige overgangen
+ public static let easingStandard: (Double, Double, Double, Double) = (0.4, 0, 0.2, 1)
+ /// Toets indrukken
+ public static let keyPress: Double = 0.05
+ /// Toets loslaten
+ public static let keyRelease: Double = 0.09
+ /// Lampje aan
+ public static let lampOn: Double = 0.03
+ /// Lampje uit: dooft langzaam, als een gloeidraad
+ public static let lampOff: Double = 0.2
+ /// VU-ballistiek: 300 ms om de stand te bereiken
+ public static let needleResponse: Double = 0.3
+ /// Veerdemping; geeft ongeveer 1,5 % overschot
+ public static let needleDamping: Double = 0.8
+ /// Verversing van de wijzer
+ public static let needleUpdateHz: Double = 60
+}
diff --git a/Sources/SstUI/DynamicColor.swift b/Sources/SstUI/DynamicColor.swift
new file mode 100644
index 0000000..c31a376
--- /dev/null
+++ b/Sources/SstUI/DynamicColor.swift
@@ -0,0 +1,26 @@
+import AppKit
+import SwiftUI
+
+extension NSColor {
+ convenience init(hex: UInt32) {
+ self.init(
+ srgbRed: CGFloat((hex >> 16) & 0xFF) / 255,
+ green: CGFloat((hex >> 8) & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255,
+ alpha: 1)
+ }
+}
+
+extension Color {
+ /// A colour that switches between the Aluminium (light) and Graphite (dark) value with the appearance.
+ static func dynamic(light: UInt32, dark: UInt32) -> Color {
+ Color(nsColor: NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(hex: dark) : NSColor(hex: light)
+ })
+ }
+
+ /// The same colour in both finishes (shadow bases).
+ static func fixed(hex: UInt32) -> Color {
+ Color(nsColor: NSColor(hex: hex))
+ }
+}
diff --git a/Sources/SstUI/TextStyle.swift b/Sources/SstUI/TextStyle.swift
new file mode 100644
index 0000000..8896cab
--- /dev/null
+++ b/Sources/SstUI/TextStyle.swift
@@ -0,0 +1,78 @@
+import SwiftUI
+
+public struct TextStyle: Equatable {
+ public let family: FontFamily
+ public let size: CGFloat
+ public let weight: Int
+ /// In em.
+ public let tracking: CGFloat
+ public let line: CGFloat
+ public let caps: Bool
+ public let tabular: Bool
+
+ public init(family: FontFamily, size: CGFloat, weight: Int, tracking: CGFloat, line: CGFloat, caps: Bool, tabular: Bool) {
+ self.family = family
+ self.size = size
+ self.weight = weight
+ self.tracking = tracking
+ self.line = line
+ self.caps = caps
+ self.tabular = tabular
+ }
+
+ var fontWeight: Font.Weight {
+ switch weight {
+ case ..<450: .regular
+ case 450..<550: .medium
+ case 550..<650: .semibold
+ default: .bold
+ }
+ }
+
+ public var font: Font {
+ let base: Font
+ switch family {
+ case .mono: base = .system(size: size, weight: fontWeight, design: .monospaced)
+ case .text: base = .system(size: size, weight: fontWeight)
+ case .chrome: base = .custom(family.rawValue, size: size).weight(fontWeight)
+ }
+ return tabular ? base.monospacedDigit() : base
+ }
+}
+
+public extension View {
+ /// Font, tracking, line spacing and capitalisation from a token text style.
+ func textStyle(_ style: TextStyle) -> some View {
+ self.font(style.font)
+ .tracking(style.tracking * style.size)
+ .lineSpacing(max(0, style.line - style.size * 1.2))
+ .textCase(style.caps ? .uppercase : nil)
+ }
+}
+
+/// One layer of a layered shadow token. Inset layers are drawn inside the shape.
+public struct ShadowLayer: Equatable {
+ public let inset: Bool
+ public let x: CGFloat
+ public let y: CGFloat
+ public let blur: CGFloat
+ public let spread: CGFloat
+ public let hex: UInt32
+ public let alphaLight: Double
+ public let alphaDark: Double
+
+ public init(inset: Bool, x: CGFloat, y: CGFloat, blur: CGFloat, spread: CGFloat, hex: UInt32, alphaLight: Double, alphaDark: Double) {
+ self.inset = inset
+ self.x = x
+ self.y = y
+ self.blur = blur
+ self.spread = spread
+ self.hex = hex
+ self.alphaLight = alphaLight
+ self.alphaDark = alphaDark
+ }
+
+ public func color(_ scheme: ColorScheme) -> Color {
+ Color.fixed(hex: hex).opacity(scheme == .dark ? alphaDark : alphaLight)
+ }
+}
diff --git a/Tests/SstUITests/TokenIntegrityTests.swift b/Tests/SstUITests/TokenIntegrityTests.swift
new file mode 100644
index 0000000..4e4f769
--- /dev/null
+++ b/Tests/SstUITests/TokenIntegrityTests.swift
@@ -0,0 +1,58 @@
+import Foundation
+import Testing
+
+/// Root of the package: Tests/SstUITests/ → three levels up.
+private let root = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+
+struct TokenIntegrityTests {
+ @Test func generatedSwiftMatchesTokensJSONAndContrastHolds() throws {
+ #expect(
+ FileManager.default.fileExists(atPath: root.appending(path: "Sources/SstUI/DesignTokens.swift").path),
+ "Sources/SstUI/DesignTokens.swift ontbreekt: run `python3 Design/build_tokens.py`.")
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = ["python3", "Design/build_tokens.py", "--check"]
+ process.currentDirectoryURL = root
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+ try process.run()
+ process.waitUntilExit()
+ let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ #expect(process.terminationStatus == 0, "Run `python3 Design/build_tokens.py`.\n\(output.suffix(600))")
+ }
+
+ /// Views take every colour, size and time from the tokens. A line may opt out with `// token-exempt: reason`.
+ @Test func viewsContainNoLooseDesignValues() throws {
+ let forbidden: [(name: String, pattern: String)] = [
+ ("hex colour", #"0x[0-9A-Fa-f]{6}\b"#),
+ ("component colour", #"(NS)?Color\((red|white|hue):"#),
+ ("literal font size", #"\.system\(size:\s*\d"#),
+ ("literal corner radius", #"cornerRadius:\s*\d|\.cornerRadius\(\s*\d"#),
+ ("literal padding", #"\.padding\(\s*(\.\w+\s*,\s*)?\d"#),
+ ("literal frame size", #"(width|height|minWidth|minHeight|maxWidth|maxHeight|idealWidth|idealHeight):\s*[1-9]"#),
+ ("literal line width", #"lineWidth:\s*\d"#),
+ ("literal opacity", #"\.opacity\(\s*[0-9]"#),
+ ]
+ let skipped: Set = ["DesignTokens.swift", "DynamicColor.swift"]
+ var directories = [root.appending(path: "Sources/SstUI")]
+ let app = root.appending(path: "Sources/Sst")
+ if FileManager.default.fileExists(atPath: app.path) { directories.append(app) }
+
+ var problems: [String] = []
+ for directory in directories {
+ let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
+ .filter { $0.pathExtension == "swift" && !skipped.contains($0.lastPathComponent) }
+ for file in files {
+ let lines = try String(contentsOf: file, encoding: .utf8).components(separatedBy: "\n")
+ for (index, line) in lines.enumerated() where !line.contains("token-exempt") {
+ for rule in forbidden where line.range(of: rule.pattern, options: .regularExpression) != nil {
+ problems.append("\(file.lastPathComponent):\(index + 1) \(rule.name): \(line.trimmingCharacters(in: .whitespaces))")
+ }
+ }
+ }
+ }
+ #expect(problems.isEmpty, "Loose design values:\n\(problems.joined(separator: "\n"))")
+ }
+}
diff --git a/docs/design/tokens-preview.html b/docs/design/tokens-preview.html
index de04656..c9f9b10 100644
--- a/docs/design/tokens-preview.html
+++ b/docs/design/tokens-preview.html
@@ -106,7 +106,7 @@
-
+
@@ -264,29 +264,29 @@ $('#rad').innerHTML = Object.entries(T.radius).map(([n, r]) => `
`;
s += ``;
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 += ``;
- s += ``;
+ s += ``;
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 += ``;
- if (major) { const [tx, ty] = pt(R - 24, a); s += `${v > 0 ? '+' + v : v}`; }
+ if (major) { const [tx, ty] = pt(R - VU['vu.labelInset'].value, a); s += `${v > 0 ? '+' + v : v}`; }
}
s += `VU`;
s += `SST · FIELD RECORDER`;
- s += ``;
+ s += ``;
s += `PEAK`;
s += ``;
return s;
@@ -304,9 +304,9 @@ function digit(ch, ox, lit) {
return `` + Object.entries(P).map(([k, pts]) => ``).join('') + ``;
}
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 += `` + (ch === ':' ? `` : ''); x += 10; }
+ if (ch === ':' || ch === '.') { out += `` + (ch === ':' ? `` : ''); x += SEG.colonWidth.value; }
else { out += digit(ch, x, true); x += adv; }
}
return ``;
@@ -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;
diff --git a/docs/design/tokens.md b/docs/design/tokens.md
index 0a93212..6f0937c 100644
--- a/docs/design/tokens.md
+++ b/docs/design/tokens.md
@@ -92,6 +92,10 @@ Verwijs ernaar als `color.`. Kolommen: ALUMINIUM (licht) en GRAPHITE (donk
| `opacity.mark` | 0.3 | Amber markering achter een gecorrigeerd woord |
| `opacity.disabled` | 0.45 | Uitgeschakelde bediening |
| `opacity.unlit` | 1.0 | Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie |
+| `opacity.pressedRecord` | 0.85 | Ingedrukte REC-toets: het rood dooft licht |
+| `opacity.specularOn` | 0.6 | Lichtvlekje op een brandend lampje |
+| `opacity.specularOff` | 0.3 | Lichtvlekje op een gedoofd lampje |
+| `opacity.caption` | 0.75 | Kleine opschriften op de wijzerplaat |
## Lettertypen
@@ -125,6 +129,8 @@ Verwijs ernaar als `color.`. Kolommen: ALUMINIUM (licht) en GRAPHITE (donk
| `segment.thickness` | 4 | Dikte van één segment |
| `segment.gap` | 1 | Ruimte tussen segmenten |
| `segment.slant` | 0 | Schuinte in graden; 0 = rechtop |
+| `segment.advance` | 26 | Afstand van cijfer tot cijfer |
+| `segment.colonWidth` | 10 | Breedte van een dubbele punt of punt |
## Ruimte
@@ -254,6 +260,13 @@ Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst
| `meter.vu.height` | 150 | Hoogte van de meter |
| `meter.vu.needleWidth` | 1.5 | Dikte van de wijzer |
| `meter.vu.ticks` | -20 -10 -7 -5 -3 0 +3 | Genummerde schaalmarkeringen |
+| `meter.vu.pivotOffset` | 22 | Draaipunt van de wijzer, onder de onderrand van de wijzerplaat |
+| `meter.vu.scaleRadius` | 142 | Straal van de schaalboog vanaf het draaipunt |
+| `meter.vu.needleLength` | 146 | Lengte van de wijzer |
+| `meter.vu.tickMajor` | 11 | Lengte van een genummerde streep |
+| `meter.vu.tickMinor` | 6 | Lengte van een gewone streep |
+| `meter.vu.labelInset` | 24 | Afstand van de schaalboog tot de cijfers |
+| `meter.vu.redBandWidth` | 5 | Dikte van de rode band vanaf 0 VU |
| `meter.led.segments` | 12 | Aantal segmenten: 8 groen, 3 amber, 1 rood |
| `meter.led.segmentSize` | 6x14 | Segment in pt (breedte x hoogte) |
| `meter.led.gap` | 2 | Ruimte tussen segmenten |