Files
sst/docs/superpowers/plans/2026-10-01-app-3-ui-kit.md
T
2026-10-01 14:32:04 +02:00

64 KiB
Raw Blame History

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, Segment uit Task 1.

  • Produces:

    • VUScale.vu(fromDbfs:), fraction(forVU:), fraction(forDbfs:), angle(forFraction:) (radialen vanaf verticaal)
    • NeedleSpring(response:damping:position:) met step(target:dt:) -> Double en position
    • SegmentID (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) en LampView(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 terwijl isActive (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) en SstGallery --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:

  1. De wijzer reageert met een korte, rustige overschrijding (niet stuiterend) en valt terug naar links bij stilte.
  2. De piek-LED op de meter gaat aan boven 0 VU en blijft ongeveer 1 s branden.
  3. REC/STOP: de REC-toets blijft ingedrukt zolang er wordt "opgenomen"; de lamp gaat snel aan en dooft traag.
  4. Klik op een toets: die zakt 1 pt en wordt donkerder.
  5. 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, PaperList als PaperBackground + rijen in fase 5), geborstelde textuur gecachet (BrushedTexture.light/dark zijn statics), meter 60 Hz alleen actief, VoiceOver-labels, "Verminder beweging". PaperList zelf (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/caption zijn de namen die de generator uit tokens.json maakt 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). ImageRenderer toont geen native tekstvelden; de galerij vervangt het zoekveld daarom in snapshotmodus.