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 `${out}`; @@ -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 `${out}`; @@ -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 |