# 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 ` (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: ```swift .target(name: "SstUI"), ``` en bij de testtargets: ```swift .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`: ```swift import Foundation import Testing /// Root of the package: Tests/SstUITests/ → three levels up. private let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() struct TokenIntegrityTests { @Test func generatedSwiftMatchesTokensJSONAndContrastHolds() throws { #expect( FileManager.default.fileExists(atPath: root.appending(path: "Sources/SstUI/DesignTokens.swift").path), "Sources/SstUI/DesignTokens.swift ontbreekt: run `python3 Design/build_tokens.py`.") let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = ["python3", "Design/build_tokens.py", "--check"] process.currentDirectoryURL = root let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe try process.run() process.waitUntilExit() let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" #expect(process.terminationStatus == 0, "Run `python3 Design/build_tokens.py`.\n\(output.suffix(600))") } /// Views take every colour, size and time from the tokens. A line may opt out with `// token-exempt: reason`. @Test func viewsContainNoLooseDesignValues() throws { let forbidden: [(name: String, pattern: String)] = [ ("hex colour", #"0x[0-9A-Fa-f]{6}\b"#), ("component colour", #"(NS)?Color\((red|white|hue):"#), ("literal font size", #"\.system\(size:\s*\d"#), ("literal corner radius", #"cornerRadius:\s*\d|\.cornerRadius\(\s*\d"#), ("literal padding", #"\.padding\(\s*(\.\w+\s*,\s*)?\d"#), ("literal frame size", #"(width|height|minWidth|minHeight|maxWidth|maxHeight|idealWidth|idealHeight):\s*[1-9]"#), ("literal line width", #"lineWidth:\s*\d"#), ("literal opacity", #"\.opacity\(\s*[0-9]"#), ] let skipped: Set = ["DesignTokens.swift", "DynamicColor.swift"] var directories = [root.appending(path: "Sources/SstUI")] let app = root.appending(path: "Sources/Sst") if FileManager.default.fileExists(atPath: app.path) { directories.append(app) } var problems: [String] = [] for directory in directories { let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) .filter { $0.pathExtension == "swift" && !skipped.contains($0.lastPathComponent) } for file in files { let lines = try String(contentsOf: file, encoding: .utf8).components(separatedBy: "\n") for (index, line) in lines.enumerated() where !line.contains("token-exempt") { for rule in forbidden where line.range(of: rule.pattern, options: .regularExpression) != nil { problems.append("\(file.lastPathComponent):\(index + 1) \(rule.name): \(line.trimmingCharacters(in: .whitespaces))") } } } } #expect(problems.isEmpty, "Loose design values:\n\(problems.joined(separator: "\n"))") } } ``` - [ ] **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`: ```python #!/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): ```python # --- 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`: ```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`: ```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** ```bash 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 " ``` --- ### 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`, `SegmentGlyph.polygon(_:) -> [CGPoint]` - `LEDBar.tone(forSegment:segments:)` (`.green/.amber/.red`), `LEDBar.litCount(forFraction:segments:)` - [ ] **Step 1: Schrijf de falende tests** `Tests/SstUITests/InstrumentsTests.swift`: ```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.. 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`: ```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] = [ "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 { 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** ```bash 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 " ``` --- ### 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`: ```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.. 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** ```bash git add Sources/SstUI git commit -m "feat: add physical surfaces, brushed metal and silkscreen label" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### 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`: ```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** ```bash git add Sources/SstUI git commit -m "feat: add physical key button style and lamp" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### 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`: ```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`: ```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.. 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** ```bash 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 " ``` --- ### 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 ` (rendert de afwerkingen naar een PNG en stopt). - [ ] **Step 1: Target toevoegen** In `Package.swift`, voeg bij `targets` toe: ```swift .executableTarget(name: "SstGallery", dependencies: ["SstUI"]), ``` - [ ] **Step 2: Implementatie** `Sources/SstGallery/GalleryApp.swift`: ```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. ```bash git add Package.swift Sources/SstGallery git commit -m "feat: add component gallery with snapshot mode" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ## 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.