64 KiB
App fase 3 — SstUI (ontwerpsysteem in code en componenten) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: De goedgekeurde ontwerp-tokens als Swift-code, plus de herbruikbare componenten van de veldrecorder-stijl (oppervlakken, toetsen, lampjes, VU-meter, segmentteller, LED-balk) en een galerij om ze in beide afwerkingen te bekijken.
Architecture: Design/tokens.json blijft de enige bron. Design/build_tokens.py genereert daaruit nu ook Sources/SstUI/DesignTokens.swift (en --check bewaakt dat ze gelijk blijven). Componenten gebruiken uitsluitend die constanten; een test scant de broncode op losse kleuren en maten. Instrument-logica (schaal, wijzerveer, segmenten) is puur en getest; tekenwerk zit in Canvas-views. Het target SstUI hangt van niets anders in het pakket af.
Tech Stack: Swift 6.4 (language mode 5), SwiftUI, AppKit (NSColor, NSImage), Swift Testing, Python 3 (generator).
Spec: docs/superpowers/specs/2026-10-01-macos-app-design.md (sectie "Ontwerpsysteem in code") en docs/design/tokens.md
Global Constraints
- Geen losse waarden in componenten: kleuren, maten, hoeken, randen, tijden komen uit
Palette,Space,Radius,Border,Size,Layout,Opacity,Typography,Shadows,Motion,MeterTokens,Segment,Texture. Een regel mag afwijken met// token-exempt: reden. - Twee afwerkingen via dynamische kleuren: Aluminium (licht) en Graphite (donker), gevolgd uit het uiterlijk van macOS.
- Eén accent:
Palette.accentRecord; amber en groen alleen voor niveau-indicatoren en markeringen; gloed alleen op lampjes, nooit op tekst. - Wijzer: 300 ms respons, demping 0,80 (1,0 bij "Verminder beweging"); verversing 60 Hz en alleen terwijl de meter actief is (plus 1,5 s uitzweven).
- 0 VU = −20 dBFS; schaal −20…+3 VU; vanaf 0 VU rood.
- Toegankelijkheid: tekstparen ≥ 4,5:1 (bewaakt door het tokenscript); focus-ring
Border.focus+Palette.lineFocus; meter en teller hebben een toegankelijkheidslabel. - Elke commit eindigt met de trailer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>(tweede-m).
Bestandsstructuur
Design/tokens.json (wijzig) extra meetwaarden en opaciteiten
Design/preview.template.html (wijzig) leest die waarden uit de tokens
Design/build_tokens.py (wijzig) Swift-uitvoer en --check
Package.swift (wijzig) SstUI, SstUITests, SstGallery
Sources/SstUI/DesignTokens.swift (gegenereerd)
Sources/SstUI/DynamicColor.swift (nieuw) Color.dynamic / Color.fixed
Sources/SstUI/TextStyle.swift (nieuw) TextStyle, ShadowLayer, .textStyle(_:)
Sources/SstUI/Instruments.swift (nieuw) VUScale, NeedleSpring, SegmentGlyph, LEDBar
Sources/SstUI/Surfaces.swift (nieuw) PhysicalSurface, Panel/Well/Display/Paper, SeamDivider, SilkLabel, BrushedBackground
Sources/SstUI/Controls.swift (nieuw) KeyButtonStyle, LampView
Sources/SstUI/VUMeterView.swift (nieuw)
Sources/SstUI/CounterAndLEDs.swift (nieuw) SegmentCounterView, LEDBarView
Sources/SstGallery/GalleryApp.swift (nieuw) galerij + --snapshot
Tests/SstUITests/TokenIntegrityTests.swift, InstrumentsTests.swift
docs/design/tokens.md, tokens-preview.html (opnieuw gegenereerd)
Task 1: Tokens uitbreiden en als Swift genereren
Files:
- Modify:
Package.swift,Design/tokens.json,Design/preview.template.html,Design/build_tokens.py - Create:
Sources/SstUI/DynamicColor.swift,Sources/SstUI/TextStyle.swift,Sources/SstUI/DesignTokens.swift(gegenereerd) - Test:
Tests/SstUITests/TokenIntegrityTests.swift
Interfaces:
-
Produces (gebruikt door alle latere taken en fasen): de enums
Palette,Opacity,FontFamily,Typography(TextStyle),Segment,Space,Layout,Size,Radius,Border,Shadows([ShadowLayer];Shadows.keyPressedOffset),Texture,MeterTokens,Motion;Color.dynamic(light:dark:),Color.fixed(hex:)(intern);View.textStyle(_:);ShadowLayer.color(_:). -
Step 1: Targets toevoegen
In Package.swift, voeg bij targets toe:
.target(name: "SstUI"),
en bij de testtargets:
.testTarget(name: "SstUITests", dependencies: ["SstUI"]),
Maak een tijdelijke Sources/SstUI/Placeholder.swift met de regel import Foundation (een target heeft minstens één bronbestand nodig; je verwijdert hem in stap 6).
- Step 2: Schrijf de falende tests
Tests/SstUITests/TokenIntegrityTests.swift:
import Foundation
import Testing
/// Root of the package: Tests/SstUITests/<this file> → 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<String> = ["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"))")
}
}
- Step 3: Draai de tests, de eerste moet falen
Run: swift test --filter TokenIntegrityTests
Expected: generatedSwiftMatchesTokensJSONAndContrastHolds faalt met "Sources/SstUI/DesignTokens.swift ontbreekt"; viewsContainNoLooseDesignValues slaagt (er is nog geen code).
- Step 4: Tokens, preview en generator aanpassen
Maak tijdelijk Design/apply_design_edits.py:
#!/usr/bin/env python3
"""One-off: extend Design/ for the Swift UI kit. Run once from the repo root; every replacement is asserted."""
import json
import pathlib
root = pathlib.Path(".")
def edit(path, replacements):
p = root / path
s = p.read_text()
for old, new in replacements:
assert old in s, f"{path}: pattern not found:\n{old}"
s = s.replace(old, new, 1)
p.write_text(s)
# 1. tokens.json: geometry for the meter and counter, extra opacities
edit("Design/tokens.json", [
(''' "vu.ticks": { "value": "-20 -10 -7 -5 -3 0 +3", "use": "Genummerde schaalmarkeringen" },''',
''' "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" },'''),
(''' "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" }'''),
(''' "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" }'''),
])
json.loads((root / "Design/tokens.json").read_text()) # still valid JSON
# 2. preview: read the meter and counter geometry from the tokens instead of fixed numbers
edit("Design/preview.template.html", [
(" const R = 142;", " const R = VU['vu.scaleRadius'].value;"),
("const pivot = { x: W / 2, y: H + 22 };", "const pivot = { x: W / 2, y: H + VU['vu.pivotOffset'].value };"),
("y2=\"${pivot.y - 146}\"", "y2=\"${pivot.y - VU['vu.needleLength'].value}\""),
("const [nx, ny] = pt(146, ang_);", "const [nx, ny] = pt(VU['vu.needleLength'].value, ang_);"),
("pt(R - 24, a)", "pt(R - VU['vu.labelInset'].value, a)"),
("pt(R - (major ? 11 : 6), a)", "pt(R - (major ? VU['vu.tickMajor'].value : VU['vu.tickMinor'].value), a)"),
("stroke-width=\"5\" fill=\"none\"/>`;\n for (let v", "stroke-width=\"${VU['vu.redBandWidth'].value}\" fill=\"none\"/>`;\n for (let v"),
("const adv = DW + 6;", "const adv = SEG.advance.value;"),
("x += 10; }", "x += SEG.colonWidth.value; }"),
])
# 3. generator: Swift output and --check
marker = "# --- markdown ---------------------------------------------------------------"
swift_block = pathlib.Path("Design/swift_block.txt").read_text()
edit("Design/build_tokens.py", [(marker, swift_block + "\n" + marker)])
print("Design/ updated")
en Design/swift_block.txt (de Swift-generator, wordt in build_tokens.py ingevoegd):
# --- 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")
Run vanuit de repo-root: python3 Design/apply_design_edits.py && rm Design/apply_design_edits.py Design/swift_block.txt
Expected: Design/ updated; beide tijdelijke bestanden zijn weg.
Run: python3 Design/build_tokens.py
Expected: tabel met contrastwaarden zonder FAIL, daarna wrote Sources/SstUI/DesignTokens.swift en wrote docs/design/tokens.md and docs/design/tokens-preview.html.
- Step 5: Hulpbestanden schrijven
Sources/SstUI/DynamicColor.swift:
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))
}
}
Sources/SstUI/TextStyle.swift:
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)
}
}
- Step 6: Placeholder weg, bouwen en testen
Run: rm Sources/SstUI/Placeholder.swift && swift build && swift test --filter TokenIntegrityTests
Expected: build zonder fouten of waarschuwingen; beide tests PASS.
- Step 7: Preview nog steeds in orde (handmatig, kort)
Run: open docs/design/tokens-preview.html
Expected: de meter, teller en LED-balk bewegen als eerder; geen lege vlakken. (De meetwaarden komen nu uit de tokens.)
- Step 8: Commit
git add Package.swift Design Sources/SstUI Tests/SstUITests docs/design
git commit -m "feat: generate Swift design tokens from tokens.json and add SstUI base" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 2: Instrument-logica — VUScale, NeedleSpring, SegmentGlyph, LEDBar
Files:
- Create:
Sources/SstUI/Instruments.swift - Test:
Tests/SstUITests/InstrumentsTests.swift
Interfaces:
-
Consumes:
MeterTokens,Motion,Segmentuit Task 1. -
Produces:
VUScale.vu(fromDbfs:),fraction(forVU:),fraction(forDbfs:),angle(forFraction:)(radialen vanaf verticaal)NeedleSpring(response:damping:position:)metstep(target:dt:) -> DoubleenpositionSegmentID(a…g),SegmentGlyph.lit(for:) -> Set<SegmentID>,SegmentGlyph.polygon(_:) -> [CGPoint]LEDBar.tone(forSegment:segments:)(.green/.amber/.red),LEDBar.litCount(forFraction:segments:)
-
Step 1: Schrijf de falende tests
Tests/SstUITests/InstrumentsTests.swift:
import CoreGraphics
import Testing
@testable import SstUI
struct VUScaleTests {
@Test func zeroVUIsMinusTwentyDbfs() {
#expect(VUScale.vu(fromDbfs: -20) == 0)
#expect(VUScale.vu(fromDbfs: -40) == -20)
}
@Test func fractionSpansTheScale() {
#expect(VUScale.fraction(forVU: -20) == 0)
#expect(abs(VUScale.fraction(forVU: 3) - 1) < 1e-9)
#expect(abs(VUScale.fraction(forVU: 0) - 20.0 / 23.0) < 1e-9)
}
@Test func fractionIsClampedAtBothEnds() {
#expect(VUScale.fraction(forVU: -60) == 0)
#expect(VUScale.fraction(forVU: 30) == 1.03)
#expect(VUScale.fraction(forDbfs: -120) == 0)
}
@Test func angleIsSymmetricAroundVertical() {
#expect(abs(VUScale.angle(forFraction: 0) + VUScale.angle(forFraction: 1)) < 1e-9)
#expect(abs(VUScale.angle(forFraction: 0.5)) < 1e-9)
#expect(abs(VUScale.angle(forFraction: 1) - 45 * .pi / 180) < 1e-9)
}
}
struct NeedleSpringTests {
func simulate(_ spring: NeedleSpring, seconds: Double, target: Double = 1) -> (final: Double, peak: Double) {
var spring = spring
var peak = 0.0
let dt = 1.0 / 240
for _ in 0..<Int(seconds / dt) { peak = max(peak, spring.step(target: target, dt: dt)) }
return (spring.position, peak)
}
@Test func reachesTheTargetWithinTheResponseTime() {
let result = simulate(NeedleSpring(), seconds: Motion.needleResponse)
#expect(abs(result.final - 1) < 0.02)
}
@Test func overshootsSlightlyWithTheTokenDamping() {
let result = simulate(NeedleSpring(), seconds: 1)
#expect(result.peak > 1.003)
#expect(result.peak < 1.03)
#expect(abs(result.final - 1) < 0.001)
}
@Test func criticalDampingNeverOvershoots() {
let result = simulate(NeedleSpring(damping: 1), seconds: 1)
#expect(result.peak <= 1.000001)
}
@Test func largeFrameStepsStayStable() {
var spring = NeedleSpring()
for _ in 0..<40 { spring.step(target: 1, dt: 0.05) }
#expect(abs(spring.position - 1) < 0.001)
}
@Test func restsAtTheStartWithoutATarget() {
var spring = NeedleSpring()
for _ in 0..<60 { spring.step(target: 0, dt: 1.0 / 60) }
#expect(spring.position == 0)
}
}
struct SegmentGlyphTests {
@Test func everyDigitHasASegmentPattern() {
for digit in "0123456789" { #expect(!SegmentGlyph.lit(for: digit).isEmpty) }
#expect(SegmentGlyph.lit(for: "8").count == 7)
#expect(SegmentGlyph.lit(for: "1") == [.b, .c])
#expect(SegmentGlyph.lit(for: ":").isEmpty)
}
@Test func polygonsStayInsideTheDigitBoxAndDiffer() {
var seen: [[CGPoint]] = []
for id in SegmentID.allCases {
let polygon = SegmentGlyph.polygon(id)
#expect(polygon.count == 6)
for point in polygon {
#expect(point.x >= 0 && point.x <= Segment.digitWidth)
#expect(point.y >= 0 && point.y <= Segment.digitHeight)
}
#expect(!seen.contains(polygon))
seen.append(polygon)
}
#expect(seen.count == 7)
}
}
struct LEDBarTests {
@Test func tonesFollowTheEightThreeOneSplit() {
let tones = (0..<12).map { LEDBar.tone(forSegment: $0) }
#expect(tones.prefix(8).allSatisfy { $0 == .green })
#expect(tones[8...10].allSatisfy { $0 == .amber })
#expect(tones[11] == .red)
}
@Test func litCountScalesAndClamps() {
#expect(LEDBar.litCount(forFraction: 0) == 0)
#expect(LEDBar.litCount(forFraction: 0.5) == 6)
#expect(LEDBar.litCount(forFraction: 1) == 12)
#expect(LEDBar.litCount(forFraction: 1.03) == 12)
#expect(LEDBar.litCount(forFraction: -1) == 0)
}
}
- Step 2: Draai de tests, moeten falen
Run: swift test --filter "VUScaleTests|NeedleSpringTests|SegmentGlyphTests|LEDBarTests"
Expected: compilatiefouten (cannot find 'VUScale' in scope).
- Step 3: Implementatie
Sources/SstUI/Instruments.swift:
import CoreGraphics
import Foundation
/// dBFS → VU → needle position and angle, all from the meter tokens.
public enum VUScale {
public static func vu(fromDbfs dbfs: Double) -> Double {
dbfs - MeterTokens.vuReferenceDbfs
}
/// 0 = bottom of the scale, 1 = top. The needle may swing slightly past the end (up to 1.03).
public static func fraction(forVU vu: Double) -> Double {
min(1.03, max(0, (vu - MeterTokens.vuMin) / (MeterTokens.vuMax - MeterTokens.vuMin)))
}
public static func fraction(forDbfs dbfs: Double) -> Double {
fraction(forVU: vu(fromDbfs: dbfs))
}
/// Radians from vertical; negative is left of vertical.
public static func angle(forFraction fraction: Double) -> Double {
(-MeterTokens.vuArcDegrees / 2 + MeterTokens.vuArcDegrees * fraction) * .pi / 180
}
}
/// Second-order needle: reaches ~99 % in `response` seconds, slight overshoot for damping < 1.
public struct NeedleSpring: Equatable {
public private(set) var position: Double
public private(set) var velocity: Double = 0
private let omega: Double
private let zeta: Double
public init(response: Double = Motion.needleResponse, damping: Double = Motion.needleDamping, position: Double = 0) {
self.zeta = damping
self.omega = 4.6 / (damping * response)
self.position = position
}
/// Advances the simulation by `dt` seconds in small fixed sub-steps (stable for any frame time).
@discardableResult
public mutating func step(target: Double, dt: Double) -> Double {
var remaining = max(0, dt)
let h = 1.0 / 240.0
while remaining > 1e-9 {
let s = min(h, remaining)
let acceleration = omega * omega * (target - position) - 2 * zeta * omega * velocity
velocity += acceleration * s
position += velocity * s
remaining -= s
}
return position
}
}
public enum SegmentID: CaseIterable, Hashable {
case a, b, c, d, e, f, g
}
/// Which segments of a seven-segment digit are lit, and where each segment sits (from the segment tokens).
public enum SegmentGlyph {
static let map: [Character: Set<SegmentID>] = [
"0": [.a, .b, .c, .d, .e, .f], "1": [.b, .c], "2": [.a, .b, .d, .e, .g], "3": [.a, .b, .c, .d, .g],
"4": [.b, .c, .f, .g], "5": [.a, .c, .d, .f, .g], "6": [.a, .c, .d, .e, .f, .g], "7": [.a, .b, .c],
"8": Set(SegmentID.allCases), "9": [.a, .b, .c, .d, .f, .g],
]
public static func lit(for character: Character) -> Set<SegmentID> {
map[character] ?? []
}
/// Hexagonal bar for one segment, inside a `Segment.digitWidth` × `Segment.digitHeight` box.
public static func polygon(_ id: SegmentID) -> [CGPoint] {
let width = Segment.digitWidth, height = Segment.digitHeight
let h = Segment.thickness / 2, gap = Segment.gap
let left = h, right = width - h, mid = height / 2
func horizontal(_ x0: CGFloat, _ x1: CGFloat, _ cy: CGFloat) -> [CGPoint] {
[CGPoint(x: x0, y: cy), CGPoint(x: x0 + h, y: cy - h), CGPoint(x: x1 - h, y: cy - h),
CGPoint(x: x1, y: cy), CGPoint(x: x1 - h, y: cy + h), CGPoint(x: x0 + h, y: cy + h)]
}
func vertical(_ cx: CGFloat, _ y0: CGFloat, _ y1: CGFloat) -> [CGPoint] {
[CGPoint(x: cx, y: y0), CGPoint(x: cx + h, y: y0 + h), CGPoint(x: cx + h, y: y1 - h),
CGPoint(x: cx, y: y1), CGPoint(x: cx - h, y: y1 - h), CGPoint(x: cx - h, y: y0 + h)]
}
let x0 = left + h + gap, x1 = right - h - gap
switch id {
case .a: return horizontal(x0, x1, h)
case .g: return horizontal(x0, x1, mid)
case .d: return horizontal(x0, x1, height - h)
case .f: return vertical(left, h + h + gap, mid - h - gap)
case .b: return vertical(right, h + h + gap, mid - h - gap)
case .e: return vertical(left, mid + h + gap, height - h - h - gap)
case .c: return vertical(right, mid + h + gap, height - h - h - gap)
}
}
}
public enum LEDBar {
public enum Tone { case green, amber, red }
/// The last segment is red, the three before it amber, the rest green.
public static func tone(forSegment index: Int, segments: Int = MeterTokens.ledSegments) -> Tone {
if index >= segments - 1 { return .red }
if index >= segments - 4 { return .amber }
return .green
}
public static func litCount(forFraction fraction: Double, segments: Int = MeterTokens.ledSegments) -> Int {
Int((min(1, max(0, fraction)) * Double(segments)).rounded())
}
}
- Step 4: Draai alle tests
Run: swift test --filter SstUITests
Expected: 13 instrumenttests + 2 integriteitstests PASS.
- Step 5: Commit
git add Sources/SstUI Tests/SstUITests
git commit -m "feat: add instrument logic (VU scale, needle spring, segment glyphs, LED bar)" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 3: Oppervlakken en materiaal
Files:
- Create:
Sources/SstUI/Surfaces.swift
Interfaces:
- Consumes:
Palette,Shadows,Radius,Border,Texture,Typography(Task 1);ShadowLayer(Task 1). - Produces (SwiftUI-views, alle
public):PhysicalSurface(fill:cornerRadius:shadows:edge:),PanelBackground(),WellBackground(),DisplayBackground(),PaperBackground(),SeamDivider(),SilkLabel(_:style:),BrushedBackground().
Tekenwerk wordt visueel getoetst in Task 6; hier moet het compileren en door de token-scan komen.
- Step 1: Implementatie
Sources/SstUI/Surfaces.swift:
import AppKit
import SwiftUI
/// A shape with a fill, layered drop shadows, layered inset shadows and a hairline edge: a physical part.
public struct PhysicalSurface: View {
private let fill: AnyShapeStyle
private let cornerRadius: CGFloat
private let shadows: [ShadowLayer]
private let edge: Color?
@Environment(\.colorScheme) private var scheme
public init(fill: AnyShapeStyle, cornerRadius: CGFloat, shadows: [ShadowLayer], edge: Color? = Palette.lineSeamDark) {
self.fill = fill
self.cornerRadius = cornerRadius
self.shadows = shadows
self.edge = edge
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .circular)
ZStack {
shadows.filter { !$0.inset }.reduce(AnyView(shape.fill(fill))) { view, layer in
AnyView(view.shadow(color: layer.color(scheme), radius: layer.blur / 2, x: layer.x, y: layer.y))
}
ForEach(Array(shadows.filter(\.inset).enumerated()), id: \.offset) { _, layer in
InsetShadow(layer: layer, cornerRadius: cornerRadius)
}
if let edge {
shape.strokeBorder(edge, lineWidth: Border.hairline)
}
}
}
}
/// A shadow cast inwards from the edge of a rounded rectangle.
struct InsetShadow: View {
let layer: ShadowLayer
let cornerRadius: CGFloat
@Environment(\.colorScheme) private var scheme
var body: some View {
Canvas { context, size in
let rect = CGRect(origin: .zero, size: size)
context.clip(to: Path(roundedRect: rect, cornerRadius: cornerRadius))
context.drawLayer { inner in
inner.addFilter(.blur(radius: layer.blur / 2))
let hole = rect.insetBy(dx: layer.spread, dy: layer.spread).offsetBy(dx: layer.x, dy: layer.y)
var frame = Path(rect.insetBy(dx: -size.width, dy: -size.height))
frame.addPath(Path(roundedRect: hole, cornerRadius: max(0, cornerRadius - layer.spread)))
inner.fill(frame, with: .color(layer.color(scheme)), style: FillStyle(eoFill: true))
}
}
.allowsHitTesting(false)
}
}
/// Faceplate of matt plastic, sitting above the chassis.
public struct PanelBackground: View {
public init() {}
public var body: some View {
PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePanel), cornerRadius: Radius.none, shadows: Shadows.panel)
}
}
/// A recessed area (input fields, display windows).
public struct WellBackground: View {
public init() {}
public var body: some View {
PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceWell), cornerRadius: Radius.none, shadows: Shadows.well)
}
}
/// Dark display glass for the counter.
public struct DisplayBackground: View {
public init() {}
public var body: some View {
PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceDisplay), cornerRadius: Radius.none, shadows: Shadows.well)
}
}
/// Cassette-label cream, for lists of transcripts and dictionary entries.
public struct PaperBackground: View {
public init() {}
public var body: some View {
PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePaper), cornerRadius: Radius.none, shadows: Shadows.well)
}
}
/// An engraved groove: a dark line with a light line below it.
public struct SeamDivider: View {
public init() {}
public var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Palette.lineSeamDark).frame(height: Border.hairline)
Rectangle().fill(Palette.lineSeamLight).frame(height: Border.hairline)
}
.accessibilityHidden(true)
}
}
/// Small capitals silkscreen label.
public struct SilkLabel: View {
private let text: String
private let style: TextStyle
public init(_ text: String, style: TextStyle = Typography.label) {
self.text = text
self.style = style
}
public var body: some View {
Text(text).textStyle(style).foregroundStyle(Palette.inkTertiary)
}
}
/// Brushed aluminium: a vertical gradient with a horizontal grain.
public struct BrushedBackground: View {
@Environment(\.colorScheme) private var scheme
public init() {}
public var body: some View {
ZStack {
LinearGradient(colors: [Palette.surfaceMetalHigh, Palette.surfaceMetalLow], startPoint: .top, endPoint: .bottom)
Image(nsImage: scheme == .dark ? BrushedTexture.dark : BrushedTexture.light)
.resizable(resizingMode: .tile)
}
}
}
enum BrushedTexture {
private static let side = 256
private static let gain = 2.2 // makes the token grain value visible as streak opacity
static let light = make(grain: Texture.brushedGrainLight)
static let dark = make(grain: Texture.brushedGrainDark)
private struct SplitMix64: RandomNumberGenerator {
var state: UInt64
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var z = state
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
return z ^ (z >> 31)
}
}
/// A seeded grain: every row has its own brightness and pixels vary only slightly, which gives fine horizontal streaks.
private static func make(grain: Double) -> NSImage {
var rng = SplitMix64(state: 7)
let rows = (0..<side).map { _ in Double.random(in: 0...1, using: &rng) }
var pixels = [UInt8](repeating: 0, count: side * side * 4)
for y in 0..<side {
let row = rows[y]
for x in 0..<side {
let value = row * 0.9 + Double.random(in: 0...1, using: &rng) * 0.1
let alpha = min(1, abs(value - 0.5) * 2 * grain * gain)
let shade = value > 0.5 ? 1.0 : 0.0
let index = (y * side + x) * 4
let premultiplied = UInt8(shade * alpha * 255)
pixels[index] = premultiplied
pixels[index + 1] = premultiplied
pixels[index + 2] = premultiplied
pixels[index + 3] = UInt8(alpha * 255)
}
}
let provider = CGDataProvider(data: Data(pixels) as CFData)!
let image = CGImage(
width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: side * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)!
return NSImage(cgImage: image, size: NSSize(width: side, height: side))
}
}
- Step 2: Bouwen en scannen
Run: swift build && swift test --filter TokenIntegrityTests
Expected: build zonder fouten of waarschuwingen; beide tests PASS (geen losse waarden).
- Step 3: Commit
git add Sources/SstUI
git commit -m "feat: add physical surfaces, brushed metal and silkscreen label" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 4: Toets en lampje
Files:
- Create:
Sources/SstUI/Controls.swift
Interfaces:
-
Consumes:
PhysicalSurface(Task 3), tokens (Task 1). -
Produces:
KeyButtonStyle(kind: .standard | .record, latched: Bool)(ButtonStyle) enLampView(isOn:tint: .record | .amber, large:). -
Step 1: Implementatie
Sources/SstUI/Controls.swift:
import SwiftUI
/// A physical key. `latched` keeps it looking pressed (REC while recording).
public struct KeyButtonStyle: ButtonStyle {
public enum Kind { case standard, record }
private let kind: Kind
private let latched: Bool
public init(kind: Kind = .standard, latched: Bool = false) {
self.kind = kind
self.latched = latched
}
public func makeBody(configuration: Configuration) -> some View {
KeyBody(configuration: configuration, kind: kind, latched: latched)
}
private struct KeyBody: View {
let configuration: Configuration
let kind: Kind
let latched: Bool
@Environment(\.isEnabled) private var isEnabled
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var pressed: Bool { configuration.isPressed || latched }
private var face: AnyShapeStyle {
switch (kind, pressed) {
case (.standard, false):
AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom))
case (.standard, true):
AnyShapeStyle(Palette.surfaceKeyPressed)
case (.record, false):
AnyShapeStyle(Palette.accentRecord)
case (.record, true):
AnyShapeStyle(Palette.accentRecord.opacity(Opacity.pressedRecord))
}
}
var body: some View {
configuration.label
.textStyle(Typography.control)
.foregroundStyle(kind == .record ? Palette.inkInverse : Palette.inkPrimary)
.padding(.horizontal, Space.s4)
.frame(
minWidth: kind == .record ? Size.keyRecWidth : Size.keyMinWidth,
minHeight: kind == .record ? Size.keyRecHeight : Size.controlHeight)
.background(PhysicalSurface(
fill: face, cornerRadius: Radius.sm,
shadows: pressed ? Shadows.keyPressed : Shadows.keyRaised))
.offset(y: pressed ? Shadows.keyPressedOffset : 0)
.opacity(isEnabled ? 1 : Opacity.disabled)
.animation(
reduceMotion ? nil : .timingCurve(
Motion.easingMechanical.0, Motion.easingMechanical.1, Motion.easingMechanical.2, Motion.easingMechanical.3,
duration: pressed ? Motion.keyPress : Motion.keyRelease),
value: pressed)
}
}
}
/// A round indicator lamp. The record lamp has a small glow; text never does.
public struct LampView: View {
public enum Tint { case record, amber }
private let isOn: Bool
private let tint: Tint
private let large: Bool
@Environment(\.colorScheme) private var scheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init(isOn: Bool, tint: Tint = .record, large: Bool = false) {
self.isOn = isOn
self.tint = tint
self.large = large
}
private var color: Color {
switch (tint, isOn) {
case (.record, true): Palette.accentRecord
case (.record, false): Palette.accentRecordUnlit
case (.amber, true): Palette.levelAmber
case (.amber, false): Palette.levelAmberUnlit
}
}
public var body: some View {
let diameter = large ? Size.lampLarge : Size.lampDiameter
let glow = Shadows.lampOn[0]
Circle()
.fill(color)
.overlay(
Circle().fill(RadialGradient(
colors: [Color.white.opacity(isOn ? Opacity.specularOn : Opacity.specularOff), .clear],
center: UnitPoint(x: 0.35, y: 0.3), startRadius: 0, endRadius: diameter * 0.45)))
.overlay(Circle().strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline))
.frame(width: diameter, height: diameter)
.shadow(
color: isOn && tint == .record ? glow.color(scheme) : .clear,
radius: glow.blur / 2)
.animation(
reduceMotion ? nil : .easeOut(duration: isOn ? Motion.lampOn : Motion.lampOff),
value: isOn)
.accessibilityHidden(true)
}
}
- Step 2: Bouwen en scannen
Run: swift build && swift test --filter SstUITests
Expected: build zonder fouten of waarschuwingen; alle SstUI-tests PASS.
- Step 3: Commit
git add Sources/SstUI
git commit -m "feat: add physical key button style and lamp" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 5: VU-meter, segmentteller en LED-balk
Files:
- Create:
Sources/SstUI/VUMeterView.swift,Sources/SstUI/CounterAndLEDs.swift
Interfaces:
-
Consumes:
VUScale,NeedleSpring,SegmentGlyph,LEDBar(Task 2);DisplayBackground(Task 3); tokens. -
Produces:
VUMeterView(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0)— alleen animatie terwijlisActive(en 1,5 s erna)SegmentCounterView(_ text: String)— cijfers,:en.LEDBarView(fraction: Double)
-
Step 1: Implementatie
Sources/SstUI/VUMeterView.swift:
import SwiftUI
/// Analogue VU meter with a physical needle. Pass the RMS level in dBFS; 0 VU = `MeterTokens.vuReferenceDbfs`.
/// The needle only animates while `isActive` (and briefly after, to settle), so an idle meter costs nothing.
public struct VUMeterView: View {
private let levelDbfs: Double
private let isActive: Bool
private let brand: String
@State private var needle: NeedleState
@State private var awake = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private final class NeedleState {
var spring: NeedleSpring
var dampingUsed = Motion.needleDamping
var last: TimeInterval?
var peakUntil: TimeInterval = 0
init(position: Double) {
spring = NeedleSpring(position: position)
}
}
/// - Parameter initialFraction: where the needle starts (0 = bottom of the scale); useful for previews and snapshots.
public init(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0) {
self.levelDbfs = levelDbfs
self.isActive = isActive
self.brand = brand
_needle = State(initialValue: NeedleState(position: initialFraction))
}
public var body: some View {
TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !(isActive || awake))) { timeline in
Canvas { context, size in
draw(&context, size: size, now: timeline.date.timeIntervalSinceReferenceDate)
}
}
.aspectRatio(MeterTokens.vuWidth / MeterTokens.vuHeight, contentMode: .fit)
.background(Palette.surfaceMeterFace)
.overlay(
RoundedRectangle(cornerRadius: Radius.bezel, style: .circular)
.strokeBorder(Palette.lineSeamDark, lineWidth: Border.bezel))
.clipShape(RoundedRectangle(cornerRadius: Radius.bezel, style: .circular))
.onChange(of: isActive) { _, active in
if active {
awake = true
} else {
Task {
try? await Task.sleep(for: .seconds(1.5))
awake = false
}
}
}
.accessibilityElement()
.accessibilityLabel("Niveau")
.accessibilityValue(spokenValue)
}
/// Rounded to steps of 3 VU so VoiceOver is not flooded with changes.
private var spokenValue: String {
guard isActive else { return "stil" }
let vu = (VUScale.vu(fromDbfs: levelDbfs) / 3).rounded() * 3
return "\(Int(vu)) VU"
}
// MARK: drawing (design coordinates: MeterTokens.vuWidth × vuHeight)
private func draw(_ context: inout GraphicsContext, size: CGSize, now: TimeInterval) {
let width = MeterTokens.vuWidth, height = MeterTokens.vuHeight
let scale = min(size.width / width, size.height / height)
context.translateBy(x: (size.width - width * scale) / 2, y: (size.height - height * scale) / 2)
context.scaleBy(x: scale, y: scale)
context.clip(to: Path(CGRect(x: 0, y: 0, width: width, height: height)))
// physics
let damping = reduceMotion ? 1.0 : Motion.needleDamping
if needle.dampingUsed != damping {
needle.spring = NeedleSpring(damping: damping, position: needle.spring.position)
needle.dampingUsed = damping
}
let dt = needle.last.map { min(0.05, now - $0) } ?? 0
needle.last = now
let target = isActive ? VUScale.fraction(forDbfs: levelDbfs) : 0
let position = needle.spring.step(target: target, dt: dt)
let vuNow = MeterTokens.vuMin + (MeterTokens.vuMax - MeterTokens.vuMin) * position
if vuNow > 0 && isActive { needle.peakUntil = now + MeterTokens.peakHoldSeconds }
let pivot = CGPoint(x: width / 2, y: height + MeterTokens.vuPivotOffset)
func point(_ radius: CGFloat, _ fraction: Double) -> CGPoint {
let angle = VUScale.angle(forFraction: fraction)
return CGPoint(x: pivot.x + radius * CGFloat(sin(angle)), y: pivot.y - radius * CGFloat(cos(angle)))
}
func arc(_ radius: CGFloat, from: Double, to: Double) -> Path {
var path = Path()
let steps = 48
for step in 0...steps {
let p = point(radius, from + (to - from) * Double(step) / Double(steps))
step == 0 ? path.move(to: p) : path.addLine(to: p)
}
return path
}
let radius = MeterTokens.vuScaleRadius
let redFrom = VUScale.fraction(forVU: 0)
context.stroke(arc(radius, from: 0, to: redFrom), with: .color(Palette.inkOnMeter), lineWidth: Border.hairline)
context.stroke(arc(radius - Border.hairline, from: redFrom, to: 1), with: .color(Palette.accentRecord), lineWidth: MeterTokens.vuRedBandWidth)
for vu in Int(MeterTokens.vuMin)...Int(MeterTokens.vuMax) {
let major = MeterTokens.vuTicks.contains(vu)
let fraction = VUScale.fraction(forVU: Double(vu))
let color = vu > 0 ? Palette.accentRecord : Palette.inkOnMeter
var tick = Path()
tick.move(to: point(radius, fraction))
tick.addLine(to: point(radius - (major ? MeterTokens.vuTickMajor : MeterTokens.vuTickMinor), fraction))
context.stroke(tick, with: .color(color), lineWidth: Border.hairline)
if major {
let label = Text(vu > 0 ? "+\(vu)" : "\(vu)").font(Typography.labelSmall.font).foregroundStyle(color)
context.draw(label, at: point(radius - MeterTokens.vuLabelInset, fraction), anchor: .center)
}
}
context.draw(
Text("VU").font(Typography.heading.font).tracking(Typography.heading.tracking * Typography.heading.size).foregroundStyle(Palette.inkOnMeter),
at: CGPoint(x: width / 2, y: height - Space.s6), anchor: .center)
context.draw(
Text(brand).font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter.opacity(Opacity.caption)),
at: CGPoint(x: Space.s4, y: height - Space.s3), anchor: .leading)
// peak lamp
let lampCenter = CGPoint(x: width - Space.s6, y: Space.s6)
let lampRadius = Size.lampDiameter / 2
let peakLit = now < needle.peakUntil
context.fill(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)),
with: .color(peakLit ? Palette.accentRecord : Palette.accentRecordUnlit))
context.stroke(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)),
with: .color(Palette.inkOnMeter), lineWidth: Border.hairline)
context.draw(
Text("PEAK").font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter),
at: CGPoint(x: lampCenter.x, y: lampCenter.y + lampRadius + Space.s3), anchor: .center)
// needle
var needlePath = Path()
needlePath.move(to: pivot)
needlePath.addLine(to: point(MeterTokens.vuNeedleLength, max(-0.02, min(1.05, position))))
context.stroke(needlePath, with: .color(Palette.inkOnMeter), lineWidth: MeterTokens.vuNeedleWidth)
}
}
Sources/SstUI/CounterAndLEDs.swift:
import SwiftUI
/// Seven-segment counter on dark glass. Digits, ":" and "." are drawn; unlit segments stay faintly visible.
public struct SegmentCounterView: View {
private let text: String
public init(_ text: String) {
self.text = text
}
private var contentWidth: CGFloat {
text.reduce(0) { total, character in
total + (character.isNumber ? Segment.advance : Segment.colonWidth)
}
}
public var body: some View {
Canvas { context, _ in
var x: CGFloat = 0
for character in text {
if character.isNumber {
var origin = context
origin.translateBy(x: x, y: 0)
let lit = SegmentGlyph.lit(for: character)
for id in SegmentID.allCases {
let polygon = SegmentGlyph.polygon(id)
var path = Path()
path.addLines(polygon)
path.closeSubpath()
origin.fill(path, with: .color(lit.contains(id) ? Palette.levelAmber : Palette.displaySegmentUnlit))
}
x += Segment.advance
} else {
let dot = Segment.thickness
let inset = (Segment.colonWidth - dot) / 2
let ys: [CGFloat] = character == ":"
? [Segment.digitHeight * 0.28, Segment.digitHeight * 0.64]
: [Segment.digitHeight - dot]
for y in ys {
context.fill(Path(CGRect(x: x + inset, y: y, width: dot, height: dot)), with: .color(Palette.levelAmber))
}
x += Segment.colonWidth
}
}
}
.frame(width: contentWidth, height: Segment.digitHeight)
.padding(.horizontal, Space.s4)
.padding(.vertical, Space.s3)
.background(DisplayBackground())
.accessibilityElement()
.accessibilityLabel("Tijd")
.accessibilityValue(text)
}
}
/// Twelve-segment level bar: green, amber, then one red segment.
public struct LEDBarView: View {
private let fraction: Double
public init(fraction: Double) {
self.fraction = fraction
}
public var body: some View {
let lit = LEDBar.litCount(forFraction: fraction)
HStack(spacing: MeterTokens.ledGap) {
ForEach(0..<MeterTokens.ledSegments, id: \.self) { index in
RoundedRectangle(cornerRadius: Radius.sharp, style: .circular)
.fill(color(for: index, lit: index < lit))
.frame(width: MeterTokens.ledSegmentWidth, height: MeterTokens.ledSegmentHeight)
}
}
.accessibilityHidden(true)
}
private func color(for index: Int, lit: Bool) -> Color {
switch LEDBar.tone(forSegment: index) {
case .green: lit ? Palette.levelGreen : Palette.levelGreenUnlit
case .amber: lit ? Palette.levelAmber : Palette.levelAmberUnlit
case .red: lit ? Palette.accentRecord : Palette.accentRecordUnlit
}
}
}
- Step 2: Bouwen en scannen
Run: swift build && swift test --filter SstUITests
Expected: build zonder fouten of waarschuwingen; alle SstUI-tests PASS.
- Step 3: Commit
git add Sources/SstUI
git commit -m "feat: add VU meter, segment counter and LED bar views" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 6: Galerij en visuele controle
Files:
- Modify:
Package.swift - Create:
Sources/SstGallery/GalleryApp.swift
Interfaces:
-
Consumes: alle componenten uit Task 2–5.
-
Produces: executable
SstGallery(venster met beide afwerkingen, bewegende meter) enSstGallery --snapshot <pad.png>(rendert de afwerkingen naar een PNG en stopt). -
Step 1: Target toevoegen
In Package.swift, voeg bij targets toe:
.executableTarget(name: "SstGallery", dependencies: ["SstUI"]),
- Step 2: Implementatie
Sources/SstGallery/GalleryApp.swift:
import SwiftUI
import SstUI
@main
struct GalleryApp: App {
init() {
// `SstGallery --snapshot out.png` renders both finishes to a PNG and exits (for checking without a screen grab).
let args = CommandLine.arguments
guard let flag = args.firstIndex(of: "--snapshot"), args.count > flag + 1 else { return }
let path = args[flag + 1]
Task { @MainActor in
let view = HStack(spacing: 0) {
Finish(scheme: .light, title: "Aluminium", frozen: true)
Finish(scheme: .dark, title: "Graphite", frozen: true)
}
.frame(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight)
let renderer = ImageRenderer(content: view)
renderer.scale = 2
if let tiff = renderer.nsImage?.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
try? png.write(to: URL(fileURLWithPath: path))
}
exit(0)
}
}
var body: some Scene {
WindowGroup("Sst componenten") {
HStack(spacing: 0) {
Finish(scheme: .light, title: "Aluminium")
Finish(scheme: .dark, title: "Graphite")
}
.frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight)
}
.defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight)
}
}
/// Fake speech level so the meter, LEDs and counter can be judged in motion.
private func simulatedDbfs(at t: Double) -> Double {
if t.truncatingRemainder(dividingBy: 9) > 6.2 { return -90 }
let syllable = floor(t * 4.2)
let r = abs(sin(syllable * 12.9898) * 43758.5453).truncatingRemainder(dividingBy: 1)
return r > 0.12 ? -38 + r * 22 : -90
}
struct Finish: View {
let scheme: ColorScheme
let title: String
var frozen = false
@State private var recording = true
@State private var started = Date()
@State private var query = ""
var body: some View {
Group {
if frozen {
content(elapsed: 12.3, dbfs: -26)
} else {
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
let elapsed = timeline.date.timeIntervalSince(started)
let dbfs = recording ? simulatedDbfs(at: elapsed) : -120
content(elapsed: recording ? elapsed : 0, dbfs: dbfs)
}
}
}
.environment(\.colorScheme, scheme)
}
private func content(elapsed: Double, dbfs: Double) -> some View {
ZStack {
BrushedBackground()
VStack(alignment: .leading, spacing: Layout.panelGap) {
SilkLabel("Afwerking · \(title)")
VStack(alignment: .leading, spacing: Layout.panelGap) {
HStack(alignment: .top, spacing: Layout.panelGap) {
VUMeterView(levelDbfs: dbfs, isActive: recording, initialFraction: frozen ? VUScale.fraction(forDbfs: dbfs) : 0)
VStack(alignment: .leading, spacing: Layout.panelGap) {
SilkLabel("Tijd", style: Typography.labelSmall)
SegmentCounterView(Self.clock(elapsed))
SilkLabel("Niveau", style: Typography.labelSmall)
LEDBarView(fraction: VUScale.fraction(forDbfs: dbfs))
}
}
SeamDivider()
HStack(spacing: Layout.panelGap) {
Button { recording.toggle(); started = Date() } label: {
HStack { LampView(isOn: recording); Text("Rec") }
}
.buttonStyle(KeyButtonStyle(kind: .record, latched: recording))
Button("Stop") { recording = false }.buttonStyle(KeyButtonStyle())
Button("Kopieer") {}.buttonStyle(KeyButtonStyle())
Button("Uit") {}.buttonStyle(KeyButtonStyle()).disabled(true)
Spacer()
LampView(isOn: recording, large: true)
LampView(isOn: true, tint: .amber)
}
}
.padding(Layout.panelPadding)
.background(PanelBackground())
VStack(alignment: .leading, spacing: 0) {
row("14:02:11", "Ik wil de meeting verplaatsen naar tomorrow morning.", selected: false)
row("14:03:40", "Hij stuurt het door naar Claude Code vandaag.", selected: true)
}
.background(PaperBackground())
HStack {
SilkLabel("Zoek")
Group {
if frozen {
Text("claude").textStyle(Typography.body).frame(maxWidth: .infinity, alignment: .leading)
} else {
TextField("", text: $query).textFieldStyle(.plain).textStyle(Typography.body)
}
}
.padding(Space.s3).background(WellBackground())
}
}
.padding(Layout.panelPadding)
}
}
private func row(_ time: String, _ text: String, selected: Bool) -> some View {
HStack(alignment: .firstTextBaseline, spacing: Space.s4) {
Text(time).textStyle(Typography.counterSmall).foregroundStyle(Palette.inkSecondary)
Text(text).textStyle(Typography.reading).foregroundStyle(Palette.inkPrimary)
Spacer()
}
.padding(.horizontal, Space.s4).padding(.vertical, Space.s3)
.frame(minHeight: Size.rowHeight)
.background(selected ? Palette.surfaceSelection : Color.clear)
}
private static func clock(_ seconds: Double) -> String {
let total = Int(seconds * 10)
return String(format: "%02d:%02d.%d", (total / 600) % 100, (total / 10) % 60, total % 10)
}
}
- Step 3: Bouwen en een momentopname maken
Run: swift build && .build/debug/SstGallery --snapshot /tmp/sst-gallery.png && open /tmp/sst-gallery.png
Expected: een PNG met links Aluminium en rechts Graphite. Controleer visueel: geborsteld metaal is fijn en subtiel (geen grove scanlijnen); de toetsen lijken verhoogd, de REC-toets is rood met een lampje; de meter heeft een wijzer tussen −7 en −5 VU en een rode band vanaf 0; de teller toont amber segmenten met gedoofde segmenten erachter; het papier heeft een krijtstreep-achtige gedempte selectie; beide afwerkingen zijn goed leesbaar.
- Step 4: Levende controle samen met de gebruiker
Run: swift run SstGallery
Laat de gebruiker beoordelen:
- De wijzer reageert met een korte, rustige overschrijding (niet stuiterend) en valt terug naar links bij stilte.
- De piek-LED op de meter gaat aan boven 0 VU en blijft ongeveer 1 s branden.
- REC/STOP: de REC-toets blijft ingedrukt zolang er wordt "opgenomen"; de lamp gaat snel aan en dooft traag.
- Klik op een toets: die zakt 1 pt en wordt donkerder.
- Zet in Systeeminstellingen → Toegankelijkheid → Beeldscherm "Verminder beweging" aan en herstart de galerij: geen overschrijding meer van de wijzer. Noteer eventuele opmerkingen; kleine visuele bijstellingen horen bij deze taak, nieuwe tokens eerst voorleggen.
- Step 5: Alle tests en commit
Run: swift test
Expected: alle tests PASS.
git add Package.swift Sources/SstGallery
git commit -m "feat: add component gallery with snapshot mode" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Self-review
- Spec-dekking: tokens → Swift met parity- en contrasttest (Task 1), geen losse waarden (scan, Task 1), dynamische Aluminium/Graphite-kleuren, componentenlijst uit de spec (
KeyButtonStyle,LampView,PanelBackground,WellBackground,SeamDivider,SilkLabel,VUMeterView,SegmentCounterView,LEDBarView,PaperListalsPaperBackground+ rijen in fase 5), geborstelde textuur gecachet (BrushedTexture.light/darkzijn statics), meter 60 Hz alleen actief, VoiceOver-labels, "Verminder beweging".PaperListzelf (de lijstweergave) komt in fase 5; hier alleen de achtergrond. - Placeholders: geen; alle code is overgenomen uit een gecompileerde en geteste versie.
- Typeconsistentie:
Shadows.keyRaised/keyPressed/well/panel/lampOn,Shadows.keyPressedOffset,Motion.easingMechanical(tuple),MeterTokens.vu*,Opacity.pressedRecord/specularOn/specularOff/captionzijn de namen die de generator uittokens.jsonmaakt en die de componenten gebruiken. - Risico's:
NSSpellChecker/AppKit-afhankelijkheid zit niet in deze fase. Tekenwerk is alleen visueel te beoordelen (Task 6 stap 3–4).ImageRenderertoont geen native tekstvelden; de galerij vervangt het zoekveld daarom in snapshotmodus.